Skip to content

Commit 5a15b66

Browse files
committed
Account for the header when deciding whether to extend a new account
Under CPI, the `allocate` and the first write share a transaction, so an account holding between 10,144 and 10,240 bytes of data would grow by more than the realloc limit despite `--single-extend-per-tx`, since no extend plan was emitted for it. Creation paths now gate the extend plan on `ACCOUNT_HEADER_LENGTH + dataLength` via a new `needsExtend` helper. The gate is unconditional: in dense mode it only adds one extend instruction to the same transaction for that 96-byte window.
1 parent 52fdde4 commit 5a15b66

5 files changed

Lines changed: 159 additions & 6 deletions

File tree

clients/js/src/createBuffer.ts

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

2525
/**
2626
* Builds a plan that creates a brand new buffer account owned by a fresh
@@ -196,7 +196,7 @@ async function getPdaBufferInstructionPlan(
196196
programData: input.programData,
197197
seed: input.seed,
198198
}),
199-
...(dataLength > REALLOC_LIMIT
199+
...(needsExtend(dataLength)
200200
? [
201201
getExtendInstructionPlan({
202202
account: input.buffer,

clients/js/src/createMetadata.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
getExtendInstructionPlan,
3232
getWriteInstructionPlan,
3333
MetadataInput,
34-
REALLOC_LIMIT,
34+
needsExtend,
3535
resolveMetadataPda,
3636
} from './utils';
3737

@@ -134,7 +134,7 @@ export async function getCreateMetadataInstructionPlanUsingNewBuffer(
134134
programData: input.programData,
135135
seed: input.seed,
136136
}),
137-
...(input.data.length > REALLOC_LIMIT
137+
...(needsExtend(input.data.length)
138138
? [
139139
getExtendInstructionPlan({
140140
account: input.metadata,
@@ -185,7 +185,7 @@ export async function getCreateMetadataInstructionPlanUsingExistingBuffer(
185185
programData: input.programData,
186186
seed: input.seed,
187187
}),
188-
...(input.dataLength > REALLOC_LIMIT
188+
...(needsExtend(input.dataLength)
189189
? [
190190
getExtendInstructionPlan({
191191
account: input.metadata,

clients/js/src/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ export function getAccountSize(dataLength: bigint | number) {
112112
return BigInt(ACCOUNT_HEADER_LENGTH) + BigInt(dataLength);
113113
}
114114

115+
/**
116+
* Whether an account created via `allocate` needs explicit `extend`
117+
* instructions to hold `dataLength` bytes of data.
118+
*
119+
* The account grows from nothing to the header length when allocated and to
120+
* `ACCOUNT_HEADER_LENGTH + dataLength` once written, so the header must be
121+
* counted towards the realloc limit. Doing so keeps the creation valid when
122+
* the transactions are executed through a CPI, where the limit applies to the
123+
* whole transaction rather than to each instruction.
124+
*/
125+
export function needsExtend(dataLength: number): boolean {
126+
return ACCOUNT_HEADER_LENGTH + dataLength > REALLOC_LIMIT;
127+
}
128+
115129
/**
116130
* Resolves the metadata PDA address for the given input.
117131
*

clients/js/test/createMetadata.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,58 @@ it('creates a canonical metadata account using an existing buffer', async () =>
182182
});
183183
});
184184

185+
it.each([
186+
{ singleExtendPerTransaction: false, label: 'densely packed extend instructions' },
187+
{ singleExtendPerTransaction: true, label: 'a single extend instruction per transaction' },
188+
])(
189+
'creates a canonical metadata account using an existing buffer as large as the realloc limit using $label',
190+
async ({ singleExtendPerTransaction }) => {
191+
// Given the following authority and deployed program.
192+
const client = await createTestClient();
193+
const authority = await generateKeyPairSignerWithSol(client);
194+
const [program, programData] = await createDeployedProgram(client, authority);
195+
196+
// And an existing buffer holding exactly one realloc limit of data.
197+
const data = getUtf8Encoder().encode('x'.repeat(REALLOC_LIMIT));
198+
const buffer = await generateKeyPairSigner();
199+
await client.programMetadata.instructions
200+
.createBuffer({ newBuffer: buffer, authority: buffer, data })
201+
.sendTransactions();
202+
203+
// When we create a canonical metadata account using the existing buffer.
204+
await client.programMetadata.createMetadata({
205+
authority,
206+
program,
207+
programData,
208+
seed: 'idl',
209+
encoding: Encoding.Utf8,
210+
compression: Compression.None,
211+
dataSource: DataSource.Direct,
212+
format: Format.Json,
213+
buffer: buffer.address,
214+
singleExtendPerTransaction,
215+
});
216+
217+
// Then we expect the following metadata account to be created.
218+
const [metadata] = await findCanonicalPda({ program, seed: 'idl' });
219+
const account = await client.programMetadata.accounts.metadata.fetch(metadata);
220+
expect(account.data).toMatchObject(<Metadata>{
221+
discriminator: AccountDiscriminator.Metadata,
222+
program,
223+
authority: none(),
224+
mutable: true,
225+
canonical: true,
226+
seed: 'idl',
227+
encoding: Encoding.Utf8,
228+
compression: Compression.None,
229+
format: Format.Json,
230+
dataSource: DataSource.Direct,
231+
dataLength: data.length,
232+
data,
233+
});
234+
},
235+
);
236+
185237
it('creates a non-canonical metadata account', async () => {
186238
// Given the following authority and deployed program.
187239
const client = await createTestClient();

clients/js/test/extendInstructionPlan.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system';
2-
import { Address, flattenTransactionPlan, getUtf8Encoder, InstructionPlan, TransactionMessage } from '@solana/kit';
2+
import {
3+
Address,
4+
flattenTransactionPlan,
5+
generateKeyPairSigner,
6+
getUtf8Encoder,
7+
InstructionPlan,
8+
TransactionMessage,
9+
} from '@solana/kit';
310
import { expect, it } from 'vitest';
411

512
import {
@@ -9,6 +16,7 @@ import {
916
Encoding,
1017
findCanonicalPda,
1118
Format,
19+
getCreateMetadataInstructionPlanUsingExistingBuffer,
1220
getCreateMetadataInstructionPlanUsingNewBuffer,
1321
getExtendInstructionPlan,
1422
parseProgramMetadataInstruction,
@@ -163,6 +171,85 @@ it('packs the extend instructions densely when creating metadata by default', as
163171
expect(getExtendLengths(messages[0], metadata)).toEqual([REALLOC_LIMIT, REALLOC_LIMIT, 25_000 - 2 * REALLOC_LIMIT]);
164172
});
165173

174+
it('accounts for the header when the data alone fits within the realloc limit', async () => {
175+
// Given a deployed program and an existing buffer holding exactly one realloc limit of data,
176+
// which together with the account header exceeds the limit.
177+
const client = await createTestClient();
178+
const authority = await generateKeyPairSignerWithSol(client);
179+
const [program, programData] = await createDeployedProgram(client, authority);
180+
const [metadata] = await findCanonicalPda({ program, seed: 'idl' });
181+
const buffer = await generateKeyPairSigner();
182+
183+
// When we plan the metadata creation from that buffer with a single extend instruction per transaction.
184+
const plan = await getCreateMetadataInstructionPlanUsingExistingBuffer(client, {
185+
authority,
186+
buffer: buffer.address,
187+
dataLength: REALLOC_LIMIT,
188+
metadata,
189+
payer: authority,
190+
program,
191+
programData,
192+
seed: 'idl',
193+
encoding: Encoding.Utf8,
194+
compression: Compression.None,
195+
dataSource: DataSource.Direct,
196+
format: Format.Json,
197+
singleExtendPerTransaction: true,
198+
});
199+
const messages = await planMessages(client, plan);
200+
201+
// Then the allocation and a header-adjusted extend share the first transaction,
202+
// and the remaining bytes are extended before the write in the second one.
203+
expect(getInstructionTypes(messages[0], metadata)).toEqual([
204+
ProgramMetadataInstruction.Allocate,
205+
ProgramMetadataInstruction.Extend,
206+
]);
207+
expect(getExtendLengths(messages[0], metadata)).toEqual([REALLOC_LIMIT - ACCOUNT_HEADER_LENGTH]);
208+
expect(getInstructionTypes(messages[1], metadata)).toEqual([
209+
ProgramMetadataInstruction.Extend,
210+
ProgramMetadataInstruction.Write,
211+
ProgramMetadataInstruction.Initialize,
212+
]);
213+
expect(getExtendLengths(messages[1], metadata)).toEqual([ACCOUNT_HEADER_LENGTH]);
214+
expect(messages.every(message => getGrowth(message, metadata) <= REALLOC_LIMIT)).toBe(true);
215+
});
216+
217+
it('adds a single extend instruction when the data alone fits within the realloc limit by default', async () => {
218+
// Given a deployed program and an existing buffer holding exactly one realloc limit of data.
219+
const client = await createTestClient();
220+
const authority = await generateKeyPairSignerWithSol(client);
221+
const [program, programData] = await createDeployedProgram(client, authority);
222+
const [metadata] = await findCanonicalPda({ program, seed: 'idl' });
223+
const buffer = await generateKeyPairSigner();
224+
225+
// When we plan the metadata creation from that buffer without constraining the extend instructions.
226+
const plan = await getCreateMetadataInstructionPlanUsingExistingBuffer(client, {
227+
authority,
228+
buffer: buffer.address,
229+
dataLength: REALLOC_LIMIT,
230+
metadata,
231+
payer: authority,
232+
program,
233+
programData,
234+
seed: 'idl',
235+
encoding: Encoding.Utf8,
236+
compression: Compression.None,
237+
dataSource: DataSource.Direct,
238+
format: Format.Json,
239+
});
240+
const messages = await planMessages(client, plan);
241+
242+
// Then everything fits in a single transaction with one full extend instruction.
243+
expect(messages).toHaveLength(1);
244+
expect(getInstructionTypes(messages[0], metadata)).toEqual([
245+
ProgramMetadataInstruction.Allocate,
246+
ProgramMetadataInstruction.Extend,
247+
ProgramMetadataInstruction.Write,
248+
ProgramMetadataInstruction.Initialize,
249+
]);
250+
expect(getExtendLengths(messages[0], metadata)).toEqual([REALLOC_LIMIT]);
251+
});
252+
166253
async function planMessages(client: TestClient, plan: InstructionPlan): Promise<TransactionMessage[]> {
167254
const transactionPlan = await client.planTransactions(plan);
168255
return flattenTransactionPlan(transactionPlan).map(single => single.message);

0 commit comments

Comments
 (0)