Skip to content

Commit c6a73ea

Browse files
authored
Merge pull request #721 from ar-io/feat/spawn-ant-owner-distinct-from-payer
feat(solana): allow a spawned ANT's owner to differ from the payer
2 parents f8c229c + d410249 commit c6a73ea

3 files changed

Lines changed: 154 additions & 7 deletions

File tree

src/solana/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,26 @@ export type {
142142
EscrowNetwork,
143143
} from './canonical-message.js';
144144

145-
// ANT spawn (mint MPL Core asset + initialize ario-ant state in one tx)
145+
// ANT spawn (mint MPL Core asset + initialize ario-ant state in one tx).
146+
//
147+
// `buildSpawnAntInstructions` / `buildCreateAntInstruction` are exported for
148+
// callers who need to bundle a spawn into a larger transaction rather than send
149+
// it — notably a SPONSORED spawn, where one wallet pays and another owns. Both
150+
// were already public within the module and documented as such; without the
151+
// re-export, a consumer wanting that shape has to hand-copy the `CreateV1`
152+
// builder, and a copy silently drifts from the Attributes-plugin/ADR-028
153+
// authority shape that `ario_arns::buy_name` depends on (a mismatch surfaces
154+
// only later, as MPL Core 0x4 "Plugin not found", at purchase time).
146155
export {
147156
spawnSolanaANT,
157+
buildSpawnAntInstructions,
158+
buildCreateAntInstruction,
148159
ARIO_LOGO_TX_ID,
149160
DEFAULT_ANT_TRANSACTION_ID,
150161
} from './spawn-ant.js';
151162
export type {
163+
AntAttribute,
164+
SpawnAntInstructions,
152165
SpawnSolanaANTParams,
153166
SpawnSolanaANTResult,
154167
SpawnSolanaANTState,
@@ -272,6 +285,10 @@ export {
272285
estimatePriorityFeeMicroLamports,
273286
estimateQuotePriorityFeeMicroLamports,
274287
estimateWalletPriorityFeeMicroLamports,
288+
// Consumers assembling their own instruction bundles (see
289+
// `buildSpawnAntInstructions`) need the same send path the SDK uses
290+
// internally, including its compute-budget and wallet-rewrite handling.
291+
sendAndConfirm,
275292
} from './send.js';
276293
export {
277294
ACL_BOOTSTRAP_ACCOUNT_BYTES,

src/solana/spawn-ant-owner.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* A spawned ANT's OWNER may differ from the wallet that pays for it.
3+
*
4+
* `buildSpawnAntInstructions({ owner })` separates the two roles the spawn
5+
* really has: `signer` funds the MPL Core asset's rent and signs `CreateV1`,
6+
* while `owner` receives the NFT and signs `ario_ant::initialize`. That is what
7+
* a sponsored spawn needs — a service pays, the end user owns — and the chain
8+
* already supports it (`CreateV1` takes `payer` and `owner` as separate
9+
* accounts, and `owner` is not a signer there).
10+
*
11+
* These tests pin the ACCOUNT ROLES rather than the byte layout, which
12+
* `spawn-ant.test.ts` already covers: the failure mode this guards against is a
13+
* silent swap of payer and owner, which would hand custody to the wrong wallet
14+
* while still producing a perfectly well-formed transaction.
15+
*/
16+
import { strict as assert } from 'node:assert';
17+
import { describe, it } from 'node:test';
18+
19+
import {
20+
type Address,
21+
address,
22+
createNoopSigner,
23+
generateKeyPairSigner,
24+
} from '@solana/kit';
25+
26+
import { buildSpawnAntInstructions } from './spawn-ant.js';
27+
28+
const OWNER: Address = address('11111111111111111111111111111113');
29+
30+
/** MPL Core `CreateV1` account order: asset, collection, authority, payer, owner. */
31+
const CREATE_V1_PAYER_INDEX = 3;
32+
const CREATE_V1_OWNER_INDEX = 4;
33+
34+
async function build(withOwner: boolean) {
35+
const signer = await generateKeyPairSigner();
36+
const { instructions } = await buildSpawnAntInstructions({
37+
signer,
38+
state: { name: 'sponsored-name' },
39+
...(withOwner ? { owner: createNoopSigner(OWNER) } : {}),
40+
});
41+
const [createIx, initIx] = instructions;
42+
assert.ok(createIx.accounts, 'CreateV1 must carry accounts');
43+
assert.ok(initIx.accounts, 'initialize must carry accounts');
44+
return { signer, createIx, initIx };
45+
}
46+
47+
describe('buildSpawnAntInstructions owner/payer separation', () => {
48+
it('defaults the owner to the signer, unchanged from before', async () => {
49+
const { signer, createIx, initIx } = await build(false);
50+
const accounts = createIx.accounts as readonly { address: Address }[];
51+
52+
assert.equal(accounts[CREATE_V1_PAYER_INDEX].address, signer.address);
53+
assert.equal(accounts[CREATE_V1_OWNER_INDEX].address, signer.address);
54+
assert.ok(
55+
(initIx.accounts as readonly { address: Address }[]).some(
56+
(a) => a.address === signer.address,
57+
),
58+
'initialize must be signed by the signer when no owner is given',
59+
);
60+
});
61+
62+
it('mints to `owner` while `signer` still pays', async () => {
63+
const { signer, createIx } = await build(true);
64+
const accounts = createIx.accounts as readonly { address: Address }[];
65+
66+
assert.equal(
67+
accounts[CREATE_V1_PAYER_INDEX].address,
68+
signer.address,
69+
'the sponsor must remain the payer',
70+
);
71+
assert.equal(
72+
accounts[CREATE_V1_OWNER_INDEX].address,
73+
OWNER,
74+
'the ANT must be minted to the supplied owner, not the payer',
75+
);
76+
assert.notEqual(
77+
accounts[CREATE_V1_OWNER_INDEX].address,
78+
signer.address,
79+
'payer and owner must not collapse — that silently hands over custody',
80+
);
81+
});
82+
83+
// `ario_ant::initialize` declares `owner: Signer` and creates AntConfig,
84+
// AntControllers and the root AntRecord with `payer = owner`. Signing it with
85+
// the sponsor would both fail on chain and charge the wrong account.
86+
it('signs `initialize` as the OWNER, not the payer', async () => {
87+
const { signer, initIx } = await build(true);
88+
const addresses = (initIx.accounts as readonly { address: Address }[]).map(
89+
(a) => a.address,
90+
);
91+
92+
assert.ok(addresses.includes(OWNER), 'initialize must reference the owner');
93+
assert.ok(
94+
!addresses.includes(signer.address),
95+
'initialize must NOT reference the sponsor — it pins payer = owner',
96+
);
97+
});
98+
99+
it('accepts a noop signer, so a sponsor can build for a remote owner', async () => {
100+
const { createIx } = await build(true);
101+
const accounts = createIx.accounts as readonly { address: Address }[];
102+
// The point of a noop signer: the sponsor assembles and partially signs the
103+
// transaction without holding the owner's key, and the owner adds theirs.
104+
assert.equal(accounts[CREATE_V1_OWNER_INDEX].address, OWNER);
105+
});
106+
});

src/solana/spawn-ant.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,33 @@ export async function buildSpawnAntInstructions(params: {
361361
state: SpawnSolanaANTState;
362362
antProgramId?: Address;
363363
mintSigner?: KeyPairSigner;
364+
/**
365+
* Wallet that will OWN the new ANT. Defaults to `signer`, which is the
366+
* existing behaviour — omitting it changes nothing.
367+
*
368+
* Supplying a different signer separates the two roles the spawn actually
369+
* has: `signer` stays the fee payer and `CreateV1` authority (it funds the
370+
* MPL Core asset's rent), while `owner` receives the NFT and signs
371+
* `ario_ant::initialize`. That split is what a sponsored spawn needs — a
372+
* service pays, the end user owns — and it is available on chain today:
373+
* `CreateV1` takes `payer` and `owner` as separate accounts and `owner` is
374+
* not a signer there.
375+
*
376+
* `owner` must still be a signer, because `ario_ant::initialize` declares
377+
* `owner: Signer` and pins `payer = owner` for the three PDAs it creates
378+
* (`AntConfig`, `AntControllers`, the root `AntRecord`). A sponsor
379+
* therefore also has to fund those lamports on the owner's account — most
380+
* simply with a `SystemTransfer` earlier in the same transaction. Callers
381+
* assembling a partially-signed transaction for a remote owner can pass
382+
* `createNoopSigner(ownerAddress)` and let the owner add the signature.
383+
*/
384+
owner?: SolanaSigner;
364385
}): Promise<SpawnAntInstructions> {
365386
if (!params.state?.name || params.state.name.length === 0) {
366387
throw new Error('buildSpawnAntInstructions: state.name is required');
367388
}
368389
const { signer, state, antProgramId = ARIO_ANT_PROGRAM_ID } = params;
390+
const owner = params.owner ?? signer;
369391
const mintSigner = params.mintSigner ?? (await generateKeyPairSigner());
370392
const mint = mintSigner.address;
371393

@@ -392,18 +414,18 @@ export async function buildSpawnAntInstructions(params: {
392414
//
393415
// ADR-028: the asset's UpdateAuthority is the per-asset `ant_authority` PDA
394416
// and the Attributes plugin authority is `UpdateAuthority` (→ that PDA), so
395-
// all MPL Core updates route through the ario-ant program. The owner (the
396-
// spawning signer) keeps custody of the NFT.
417+
// all MPL Core updates route through the ario-ant program. The owner keeps
418+
// custody of the NFT — by default the spawning signer, or `params.owner`
419+
// when a sponsor is paying on someone else's behalf.
397420
const [antAuthority] = await getAntAuthorityPDA(mint, antProgramId);
398421
const createIx = getCreateV1Instruction({
399422
asset: mintSigner,
400423
payer: signer,
401424
authority: signer,
402425
// `owner` MUST be explicit. MPL Core defaults owner to updateAuthority when
403426
// omitted, so with updateAuthority = the ant_authority PDA an omitted owner
404-
// would make the PROGRAM PDA the NFT owner (user loses custody). Pin it to
405-
// the spawning wallet.
406-
owner: signer.address,
427+
// would make the PROGRAM PDA the NFT owner (user loses custody).
428+
owner: owner.address,
407429
updateAuthority: antAuthority,
408430
dataState: DataState.AccountState,
409431
name: state.name,
@@ -428,7 +450,9 @@ export async function buildSpawnAntInstructions(params: {
428450
const initIx = await buildInitializeAntIx({
429451
programId: antProgramId,
430452
mint,
431-
signer,
453+
// `ario_ant::initialize` declares `owner: Signer` and creates its PDAs with
454+
// `payer = owner`, so this must be the OWNER, not the fee payer.
455+
signer: owner,
432456
state,
433457
});
434458

0 commit comments

Comments
 (0)