Skip to content

Commit a929556

Browse files
Atticusclaude
authored andcommitted
fix(solana): route oversized atomic spawn-and-buy through a lookup table
The atomic spawn-and-buy (`buyRecord` with no `processId`) sends `[CreateV1, initialize, buy_name]` in one transaction. That fits Solana's 1232-byte limit for a balance/credit-funded buy (~1116 bytes, one signature), but a multi-source funding plan appends ~3 remaining accounts per source to `buy_name` (~33 bytes each), pushing the tx over the limit — the RPC rejects it with "VersionedTransaction too large" (observed 1776 bytes for ~7 sources). Size-gate the atomic send: estimate the compiled size up front and send inline when it fits (unchanged, single signature); otherwise route the whole spawn-and-buy through the existing ephemeral Address Lookup Table path, compressing every non-signer, non-invoked-program account (the mint stays inline as a signer). A 20-source plan drops from 1776 to ~735 bytes after compression. - send.ts: add `MAX_TX_SIZE_BYTES` + `estimateCompiledTxSize`; generalize `sendWithEphemeralLookupTable` to accept an instruction array + `extraSigners`. - io-writeable.ts: add `altEligibleAddresses`; size-gate the atomic buy; update the `prescribe_epoch` caller to the array signature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWv3xbxumjJa2zPeDPZvJX
1 parent febd59c commit a929556

2 files changed

Lines changed: 148 additions & 16 deletions

File tree

src/solana/io-writeable.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ import {
240240
} from './predict-prescribed-observers.js';
241241
import {
242242
DEFAULT_COMPUTE_UNIT_LIMIT,
243+
MAX_TX_SIZE_BYTES,
244+
estimateCompiledTxSize,
243245
reclaimLookupTablesForSigner,
244246
sendAndConfirm,
245247
sendWithEphemeralLookupTable,
@@ -440,6 +442,39 @@ export const MAX_COMPOUND_BATCH = 6;
440442
* a pre-send simulation; message-modifying wallets keep this generous ceiling.
441443
*/
442444
const SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT = 800_000;
445+
/**
446+
* Collect the account addresses in `instructions` that are safe to serve from
447+
* an Address Lookup Table: every account meta EXCEPT signers (which must remain
448+
* in the static keys) and the invoked top-level program ids (a program invoked
449+
* by an instruction cannot be loaded from an ALT). `alwaysInline` pins extra
450+
* addresses static — the fee payer and any bundled mint signer. CPI-target
451+
* programs that appear only as account metas (e.g. system, token) ARE eligible.
452+
* Deduped; order-independent.
453+
*/
454+
function altEligibleAddresses(
455+
instructions: Instruction[],
456+
alwaysInline: Address[],
457+
): Address[] {
458+
const inline = new Set<string>(alwaysInline as string[]);
459+
for (const ix of instructions) {
460+
inline.add(ix.programAddress);
461+
for (const acc of ix.accounts ?? []) {
462+
if (
463+
acc.role === AccountRole.READONLY_SIGNER ||
464+
acc.role === AccountRole.WRITABLE_SIGNER
465+
) {
466+
inline.add(acc.address);
467+
}
468+
}
469+
}
470+
const eligible = new Set<Address>();
471+
for (const ix of instructions) {
472+
for (const acc of ix.accounts ?? []) {
473+
if (!inline.has(acc.address)) eligible.add(acc.address);
474+
}
475+
}
476+
return [...eligible];
477+
}
443478
/** Observation PDAs closed per tx before close_epoch (each ix carries Epoch +
444479
* Observation + payer + system accounts — keep well under the tx account cap). */
445480
const MAX_CLOSE_OBSERVATION_BATCH = 8;
@@ -1625,11 +1660,42 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
16251660
// asset's traits via CPI, so the deferred sync just mirrors them into the
16261661
// ANT's on-chain record).
16271662
if (mintSigner !== undefined) {
1628-
const sig = await this.sendTransaction(
1629-
[...spawnIxs, ix],
1630-
SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1631-
[mintSigner],
1632-
);
1663+
const spawnAndBuyIxs = [...spawnIxs, ix];
1664+
// Balance/credit-funded spawn-and-buy fits inline (~1.1 KB) and lands in
1665+
// ONE signature. But a multi-source funding plan appends per-source
1666+
// remaining accounts to `buy_name` (~33 bytes each) and can blow past
1667+
// Solana's 1232-byte limit. When that happens, route the whole
1668+
// spawn-and-buy through an ephemeral Address Lookup Table (create →
1669+
// extend → compressed v0 tx), compressing every non-signer,
1670+
// non-invoked-program account. The mint stays inline (it's a signer).
1671+
const inlineSize = estimateCompiledTxSize({
1672+
signer: this.signer,
1673+
instructions: spawnAndBuyIxs,
1674+
extraSigners: [mintSigner],
1675+
computeUnitLimit: SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1676+
});
1677+
let sig: string;
1678+
if (inlineSize <= MAX_TX_SIZE_BYTES) {
1679+
sig = await this.sendTransaction(
1680+
spawnAndBuyIxs,
1681+
SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1682+
[mintSigner],
1683+
);
1684+
} else {
1685+
sig = await sendWithEphemeralLookupTable({
1686+
rpc: this.rpc,
1687+
rpcSubscriptions: this.rpcSubscriptions,
1688+
signer: this.signer,
1689+
instructions: spawnAndBuyIxs,
1690+
lookupAddresses: altEligibleAddresses(spawnAndBuyIxs, [
1691+
this.signer.address,
1692+
mintSigner.address,
1693+
]),
1694+
commitment: this.commitment,
1695+
computeUnitLimit: SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1696+
extraSigners: [mintSigner],
1697+
});
1698+
}
16331699
await this._bootstrapSpawnedAntAcl(antPubkey, params.name);
16341700
// Surface the freshly-minted ANT's id so callers don't have to re-fetch
16351701
// the record to discover which asset the name was assigned to.
@@ -3902,7 +3968,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
39023968
rpc: this.rpc,
39033969
rpcSubscriptions: this.rpcSubscriptions,
39043970
signer: this.signer,
3905-
instruction: fullIx,
3971+
instructions: [fullIx],
39063972
lookupAddresses: remaining.map((a) => a.address),
39073973
commitment: this.commitment,
39083974
computeUnitLimit: 1_000_000,

src/solana/send.ts

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -607,36 +607,101 @@ async function logSimulationDiagnostics(
607607
}
608608
}
609609

610+
/** Solana's hard cap on a serialized transaction (signatures + message). */
611+
export const MAX_TX_SIZE_BYTES = 1232;
612+
610613
/**
611-
* Submit `instruction` in a v0 transaction whose `lookupAddresses` (read-only
612-
* accounts) are served from a freshly-created, ephemeral Address Lookup Table,
613-
* so an instruction touching far more accounts than fit inline (e.g.
614-
* `prescribe_epoch` with ≤50 observer PDAs + NameRegistry, ~2 KB of keys) still
615-
* fits Solana's 1232-byte transaction-size limit.
614+
* Compiled wire size (signatures + message) of the v0 transaction
615+
* `sendAndConfirm` would build for `instructions` — i.e. WITH the two
616+
* compute-budget instructions it always prepends and `extraSigners` attached,
617+
* but WITHOUT any lookup-table compression. Lets callers decide up front
618+
* (before prompting a wallet) whether a tx needs to be routed through an
619+
* Address Lookup Table to fit {@link MAX_TX_SIZE_BYTES}.
620+
*
621+
* Uses a zero blockhash and zero-filled signatures — neither affects the byte
622+
* length (blockhash is always 32 bytes, each signature 64) — so no RPC call is
623+
* needed.
624+
*/
625+
export function estimateCompiledTxSize({
626+
signer,
627+
instructions,
628+
extraSigners = [],
629+
computeUnitLimit = DEFAULT_COMPUTE_UNIT_LIMIT,
630+
}: {
631+
signer: TransactionSigner;
632+
instructions: Instruction[];
633+
extraSigners?: KeyPairSigner[];
634+
computeUnitLimit?: number;
635+
}): number {
636+
const message = pipe(
637+
createTransactionMessage({ version: 0 }),
638+
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
639+
(tx) =>
640+
setTransactionMessageLifetimeUsingBlockhash(
641+
{
642+
blockhash: '11111111111111111111111111111111' as never,
643+
lastValidBlockHeight: 0n,
644+
},
645+
tx,
646+
),
647+
(tx) =>
648+
appendTransactionMessageInstructions(
649+
[
650+
getSetComputeUnitLimitInstruction({ units: computeUnitLimit }),
651+
getSetComputeUnitPriceInstruction({ microLamports: 1n }),
652+
...instructions,
653+
],
654+
tx,
655+
),
656+
(tx) =>
657+
extraSigners.length > 0
658+
? addSignersToTransactionMessage(extraSigners, tx)
659+
: tx,
660+
);
661+
const compiled = compileTransaction(message);
662+
const numSigners = Object.keys(compiled.signatures).length;
663+
// wire tx = [compact-u16 sig count (1 byte for < 128 sigs)][sigs][message]
664+
return 1 + numSigners * 64 + compiled.messageBytes.length;
665+
}
666+
667+
/**
668+
* Submit `instructions` in a v0 transaction whose `lookupAddresses` (non-signer,
669+
* non-invoked-program accounts) are served from a freshly-created, ephemeral
670+
* Address Lookup Table, so a transaction touching far more accounts than fit
671+
* inline (e.g. `prescribe_epoch` with ≤50 observer PDAs, or the atomic
672+
* spawn-and-buy with a large multi-source funding plan) still fits Solana's
673+
* 1232-byte transaction-size limit.
616674
*
617675
* Three confirmed steps: create the table, extend it with the addresses (in
618676
* ≤20-address batches to stay within the extend tx size), then send
619-
* `instruction` compressed against the table. The sequential confirmations
677+
* `instructions` compressed against the table. The sequential confirmations
620678
* satisfy the rule that appended addresses are only usable the slot AFTER they
621679
* are added. `signer` is the table's authority + payer; the table's (tiny) rent
622680
* is left allocated — a future cleanup pass can deactivate + close it.
681+
*
682+
* `extraSigners` (e.g. a freshly generated ANT mint keypair when the final
683+
* transaction bundles a spawn) are attached to the compressed send. Only the
684+
* final consuming transaction carries them; the create/extend txs are signed by
685+
* `signer` alone.
623686
*/
624687
export async function sendWithEphemeralLookupTable({
625688
rpc,
626689
rpcSubscriptions,
627690
signer,
628-
instruction,
691+
instructions,
629692
lookupAddresses,
630693
commitment = 'confirmed',
631694
computeUnitLimit = 1_000_000,
695+
extraSigners = [],
632696
}: {
633697
rpc: SolanaRpc;
634698
rpcSubscriptions: SolanaRpcSubscriptions;
635699
signer: TransactionSigner;
636-
instruction: Instruction;
700+
instructions: Instruction[];
637701
lookupAddresses: Address[];
638702
commitment?: Commitment;
639703
computeUnitLimit?: number;
704+
extraSigners?: KeyPairSigner[];
640705
}): Promise<string> {
641706
const recentSlot = await rpc.getSlot({ commitment: 'finalized' }).send();
642707
const createIx = await getCreateLookupTableInstructionAsync({
@@ -683,15 +748,16 @@ export async function sendWithEphemeralLookupTable({
683748
// them. Skipping this yields "address table lookup uses an invalid index".
684749
await waitForLookupTableActive(rpc, tableAddress, lookupAddresses.length);
685750

686-
// Send the real instruction, compressed against the now-active table.
751+
// Send the real instructions, compressed against the now-active table.
687752
return sendAndConfirm({
688753
rpc,
689754
rpcSubscriptions,
690755
signer,
691-
instructions: [instruction],
756+
instructions,
692757
commitment,
693758
computeUnitLimit,
694759
addressLookupTables: { [tableAddress]: lookupAddresses },
760+
extraSigners,
695761
});
696762
}
697763

0 commit comments

Comments
 (0)