Skip to content

Commit 52fdde4

Browse files
committed
Add a single-extend-per-transaction option for exported transactions
This PR adds a `--single-extend-per-tx` CLI option (and a matching `singleExtendPerTransaction` input on the JS client's plan builders) for transactions that are exported and later executed through a CPI, e.g. by a Squads multisig. In that setting the Solana runtime applies the 10,240-byte realloc limit to the whole top-level instruction rather than to each inner instruction, so the extend instructions the client packs into a single transaction to grow a metadata or buffer account beyond 10KB fail with `InvalidRealloc`. When the option is set, a dedicated message packer ensures an account never grows by more than the realloc limit within a single transaction. It sizes each `extend` from the growth budget the candidate transaction has left, accounting for program-metadata instructions already present in it, so the first extend shares its transaction with the rent transfer and the `allocate` that creates the 96-byte header, every following extend gets its own transaction, and the rest of the plan (writes, `initialize` or `setData`) follows the last one. The option only applies to exported transactions and errors out without `--export`, since transactions run by the CLI directly execute top-level and are not affected. The default (dense) packing now computes the extend chunk sizes locally instead of relying on Kit's `getReallocMessagePackerInstructionPlan`, which emits a final 0-byte extend when the total is an exact multiple of 10,240 and leaves the account one chunk short. This is a stopgap until the upstream fix ships. `REALLOC_LIMIT` is now exported from the client and reused by the tests instead of being redeclared. Tests cover both packing modes at the planning level (per-transaction growth, exact-multiple chunking, the allocate header adjustment) and end to end with 25KB payloads through the create and update-from-buffer paths, which previously had no coverage above the realloc limit. The README documents the new option and when the Squads workflow needs it.
1 parent 2ea5f78 commit 52fdde4

18 files changed

Lines changed: 581 additions & 25 deletions

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ Using a buffer account you can split the metadata update into the uploading of t
120120
- `--priority-fees <number>`: Priority fees in micro-lamports per compute unit (default: 100000)
121121
- `--rpc <string>`: Custom RPC URL
122122
- `--export [address]`: Export transactions instead of running them. Optionally specify an override authority address.
123-
- `--export-encoding <encoding>`: How to encode exported transactions. Choices: none, utf8, base58, base64 (default: base64)
123+
- `--export-encoding <encoding>`: How to encode exported transactions. Choices: none, utf8, base58, base64, instruction-list (default: base64)
124+
- `--single-extend-per-tx`: Never grow an account by more than 10KB within a single exported transaction. Required when the exported transactions are executed through a CPI, e.g. by a multisig such as Squads. Requires `--export`.
124125
- `--tx-version <version>`: Transaction version to build. Choices: legacy, 0 (default: 0)
125126
- `-h, --help`: Show help for command
126127

@@ -150,7 +151,13 @@ Squads v3 only accepts legacy transactions. If your multisig is on Squads v3, ad
150151
npx @solana-program/program-metadata@latest write idl <program-address> --buffer <buffer-address> --export <multisig-address> --export-encoding base58 --close-buffer <your-address-to-get-the-buffer-rent-back> --tx-version legacy
151152
```
152153

153-
4. Sign the transaction in your multisig and send it
154+
If the metadata account needs to grow by more than 10KB — because it is being created with more than 10KB of data, or updated with more than 10KB of additional data — add `--single-extend-per-tx`. Multisigs execute the exported transactions through a CPI, where the Solana runtime caps the growth of an account at 10KB per transaction rather than per instruction. This option spreads the growth over several transactions accordingly, so expect more than one transaction to import:
155+
156+
```bash
157+
npx @solana-program/program-metadata@latest write idl <program-address> --buffer <buffer-address> --export <multisig-address> --export-encoding base58 --close-buffer <your-address-to-get-the-buffer-rent-back> --single-extend-per-tx
158+
```
159+
160+
4. Sign the transaction(s) in your multisig and send them in order
154161

155162
### Examples
156163

clients/js/src/cli/commands/create.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function doCreate(seed: Seed, program: Address, file: string | unde
5757
programData,
5858
seed,
5959
metadata,
60+
singleExtendPerTransaction: options.singleExtendPerTx,
6061
}),
6162
);
6263
}

clients/js/src/cli/commands/update-buffer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export async function doUpdateBuffer(buffer: Address, file: string | undefined,
4343
sourceBuffer: writeInput.buffer,
4444
closeSourceBuffer: writeInput.closeBuffer,
4545
data: newData,
46+
singleExtendPerTransaction: options.singleExtendPerTx,
4647
}),
4748
);
4849
}

clients/js/src/cli/commands/update.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function doWrite(seed: Seed, program: Address, file: string | undef
5656
program,
5757
programData,
5858
metadata: metadataAccount,
59+
singleExtendPerTransaction: options.singleExtendPerTx,
5960
}),
6061
);
6162
}

clients/js/src/cli/commands/write.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export async function doWrite(seed: Seed, program: Address, file: string | undef
5252
programData,
5353
seed,
5454
metadata: metadataAccount,
55+
singleExtendPerTransaction: options.singleExtendPerTx,
5556
}),
5657
);
5758
}

clients/js/src/cli/options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type GlobalOptions = KeypairOption &
1212
RpcOption &
1313
ExportOption &
1414
ExportEncodingOption &
15+
SingleExtendPerTxOption &
1516
TransactionVersionOption;
1617

1718
export function setGlobalOptions(command: CustomCommand) {
@@ -22,6 +23,7 @@ export function setGlobalOptions(command: CustomCommand) {
2223
.addOption(rpcOption)
2324
.addOption(exportOption)
2425
.addOption(exportEncodingOption)
26+
.addOption(singleExtendPerTxOption)
2527
.addOption(transactionVersionOption);
2628
}
2729

@@ -68,6 +70,14 @@ export const exportEncodingOption = new Option(
6870
(value: string): ExportEncoding => (value === 'instruction-list' ? 'instruction-list' : encodingParser(value)),
6971
);
7072

73+
export type SingleExtendPerTxOption = { singleExtendPerTx: boolean };
74+
export const singleExtendPerTxOption = new Option(
75+
'--single-extend-per-tx',
76+
'Never grow an account by more than 10KB within a single exported transaction (at most one "extend" instruction per transaction). ' +
77+
'Required when the exported transactions are executed through a CPI, e.g. by a multisig program such as Squads, ' +
78+
'as the runtime then applies the 10KB realloc limit per transaction instead of per instruction. Requires "--export".',
79+
).default(false);
80+
7181
export type TransactionVersion = 'legacy' | 0;
7282
export type TransactionVersionOption = { txVersion: TransactionVersion };
7383
export const transactionVersionOption = new Option(

clients/js/src/cli/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
NonCanonicalWriteOption,
5959
PayerOption,
6060
RpcOption,
61+
SingleExtendPerTxOption,
6162
WriteOptions,
6263
} from './options';
6364
import { createRetryingSolanaRpc, RetryingRpcConfig } from './rpc';
@@ -85,6 +86,7 @@ export class CustomCommand extends Command {
8586
export type Client = Awaited<ReturnType<typeof getClient>>;
8687

8788
export async function getClient(options: GlobalOptions) {
89+
assertValidExportOptions(options);
8890
const configs = getSolanaConfigs();
8991
const rpcUrl = getRpcUrl(options, configs);
9092
const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs);
@@ -111,6 +113,17 @@ export async function getClient(options: GlobalOptions) {
111113
.use(cliRunOrExport(options));
112114
}
113115

116+
/**
117+
* Rejects option combinations that only make sense when exporting
118+
* transactions. When transactions are executed directly by the CLI they run
119+
* top-level, so `--single-extend-per-tx` would only add needless transactions.
120+
*/
121+
function assertValidExportOptions(options: ExportOption & SingleExtendPerTxOption): void {
122+
if (options.singleExtendPerTx && !options.export) {
123+
logErrorAndExit('The `--single-extend-per-tx` option can only be used together with `--export`.');
124+
}
125+
}
126+
114127
/**
115128
* Shared configuration for the CLI's retrying RPC. Surfaces a warning whenever a
116129
* request is rate limited and retried, so a paused command does not appear to

clients/js/src/createBuffer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ import {
2020
PROGRAM_METADATA_PROGRAM_ADDRESS,
2121
SeedArgs,
2222
} from './generated';
23-
import { REALLOC_LIMIT } from './internals';
24-
import { getAccountSize, getExtendInstructionPlan, getWriteInstructionPlan } from './utils';
23+
import { getAccountSize, getExtendInstructionPlan, getWriteInstructionPlan, REALLOC_LIMIT } from './utils';
2524

2625
/**
2726
* Builds a plan that creates a brand new buffer account owned by a fresh
@@ -120,6 +119,7 @@ export async function getCreateCanonicalBufferInstructionPlan(
120119
program: Address;
121120
programData: Address;
122121
seed: SeedArgs;
122+
singleExtendPerTransaction?: boolean;
123123
},
124124
) {
125125
const buffer = input.buffer ?? (await findCanonicalPda({ program: input.program, seed: input.seed }))[0];
@@ -151,6 +151,7 @@ export async function getCreateNonCanonicalBufferInstructionPlan(
151151
payer: TransactionSigner;
152152
program: Address;
153153
seed: SeedArgs;
154+
singleExtendPerTransaction?: boolean;
154155
},
155156
) {
156157
const buffer =
@@ -177,6 +178,7 @@ async function getPdaBufferInstructionPlan(
177178
program: Address;
178179
programData?: Address;
179180
seed: SeedArgs;
181+
singleExtendPerTransaction?: boolean;
180182
},
181183
) {
182184
const dataLength = input.dataLength ?? input.data?.length ?? 0;
@@ -202,6 +204,7 @@ async function getPdaBufferInstructionPlan(
202204
extraLength: dataLength,
203205
program: input.program,
204206
programData: input.programData,
207+
singleExtendPerTransaction: input.singleExtendPerTransaction,
205208
}),
206209
]
207210
: []),

clients/js/src/createMetadata.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ import {
2525
InitializeInput,
2626
PROGRAM_METADATA_PROGRAM_ADDRESS,
2727
} from './generated';
28-
import { isValidInstructionPlan, REALLOC_LIMIT } from './internals';
28+
import { isValidInstructionPlan } from './internals';
2929
import {
3030
getAccountSize,
3131
getExtendInstructionPlan,
3232
getWriteInstructionPlan,
3333
MetadataInput,
34+
REALLOC_LIMIT,
3435
resolveMetadataPda,
3536
} from './utils';
3637

@@ -66,6 +67,7 @@ export async function getCreateMetadataInstructionPlan(
6667
data?: ReadonlyUint8Array;
6768
payer: TransactionSigner;
6869
closeBuffer?: Address | boolean;
70+
singleExtendPerTransaction?: boolean;
6971
},
7072
): Promise<InstructionPlan> {
7173
if (!input.buffer && !input.data) {
@@ -115,6 +117,7 @@ export async function getCreateMetadataInstructionPlanUsingNewBuffer(
115117
input: Omit<InitializeInput, 'data'> & {
116118
data: ReadonlyUint8Array;
117119
payer: TransactionSigner;
120+
singleExtendPerTransaction?: boolean;
118121
},
119122
) {
120123
const rent = await client.getMinimumBalance(Number(getAccountSize(input.data.length)));
@@ -139,6 +142,7 @@ export async function getCreateMetadataInstructionPlanUsingNewBuffer(
139142
extraLength: input.data.length,
140143
program: input.program,
141144
programData: input.programData,
145+
singleExtendPerTransaction: input.singleExtendPerTransaction,
142146
}),
143147
]
144148
: []),
@@ -164,6 +168,7 @@ export async function getCreateMetadataInstructionPlanUsingExistingBuffer(
164168
dataLength: number;
165169
payer: TransactionSigner;
166170
closeBuffer?: Address | boolean;
171+
singleExtendPerTransaction?: boolean;
167172
},
168173
) {
169174
const rent = await client.getMinimumBalance(Number(getAccountSize(input.dataLength)));
@@ -188,6 +193,7 @@ export async function getCreateMetadataInstructionPlanUsingExistingBuffer(
188193
extraLength: input.dataLength,
189194
program: input.program,
190195
programData: input.programData,
196+
singleExtendPerTransaction: input.singleExtendPerTransaction,
191197
}),
192198
]
193199
: []),

clients/js/src/internals.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { ClientWithTransactionPlanning, InstructionPlan } from '@solana/kit';
22

3-
export const REALLOC_LIMIT = 10_240;
4-
53
/**
64
* Returns `true` if the given instruction plan can be planned by the client's
75
* transaction planner without throwing — i.e. it fits within a single

0 commit comments

Comments
 (0)