Skip to content

Commit ad25c0b

Browse files
authored
Merge branch 'alpha' into ar-io-ant-update-auth
2 parents 05f2012 + 12ed070 commit ad25c0b

8 files changed

Lines changed: 458 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# [4.1.0-alpha.6](https://github.com/ar-io/ar-io-sdk/compare/v4.1.0-alpha.5...v4.1.0-alpha.6) (2026-07-18)
2+
3+
4+
### Bug Fixes
5+
6+
* **solana:** don't let sync_attributes revert the spawned-ANT ACL records ([ba3cd1e](https://github.com/ar-io/ar-io-sdk/commit/ba3cd1e415d0ff712a569e52a9a36765349d21cf))
7+
* **solana:** route oversized atomic spawn-and-buy through a lookup table ([a929556](https://github.com/ar-io/ar-io-sdk/commit/a92955603d9b34951b5073f25b35aaa3aaf47040))
8+
9+
# [4.1.0-alpha.5](https://github.com/ar-io/ar-io-sdk/compare/v4.1.0-alpha.4...v4.1.0-alpha.5) (2026-07-17)
10+
11+
12+
### Features
13+
14+
* **solana:** atomically spawn an ANT during buyRecord when no processId ([35b3dea](https://github.com/ar-io/ar-io-sdk/commit/35b3dea306b6bc3461a25122581f4c13eca07ff0))
15+
116
# [4.1.0-alpha.4](https://github.com/ar-io/ar-io-sdk/compare/v4.1.0-alpha.3...v4.1.0-alpha.4) (2026-07-02)
217

318

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1296,7 +1296,7 @@ const delegates = await ario.getAllDelegates({
12961296

12971297
</details>
12981298

1299-
### Arweave Name System (ArNS)
1299+
### ar.io Name System (ArNS)
13001300

13011301
#### `resolveArNSName({ name })`
13021302

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ar.io/sdk",
3-
"version": "4.1.0-alpha.4",
3+
"version": "4.1.0-alpha.6",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/ar-io/ar-io-sdk.git"

src/solana/io-writeable.localnet.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ import {
4141

4242
import { ANT } from '../common/ant.js';
4343
import { ARIO } from '../common/io.js';
44+
import { SolanaANTRegistryReadable } from './ant-registry-readable.js';
4445
import { getAssociatedTokenAddressKit } from './ata.js';
4546
import { deserializeWithdrawal } from './deserialize.js';
4647
import { SolanaARIOWriteable } from './io-writeable.js';
48+
import { fetchMplCoreOwner } from './mpl-core.js';
4749
import {
4850
getArioConfigPDA,
4951
getPrimaryNamePDA,
@@ -342,6 +344,62 @@ describe(
342344
);
343345
});
344346

347+
it('buyRecord without processId spawns a fresh ANT and assigns the name in one tx', async () => {
348+
// Hermetic fresh buyer: spawn-and-buy mints a new asset (rent) + pays the
349+
// ArNS price, so fund SOL + ARIO up front.
350+
const buyer = await freshSigner(scratch, 'spawn-buy');
351+
await airdrop(buyer.keypairPath, 5);
352+
mintArio(buyer.signer.address, 100_000_000_000n); // 100k ARIO
353+
354+
const buyerArio = buildArio(buyer.signer, RPC_URL!, WS_URL!);
355+
const name = 'spawnbuy' + Date.now().toString(36).slice(-6);
356+
357+
// No `processId` → the SDK spawns an ANT and assigns the name atomically
358+
// in a single [CreateV1, initialize, buy_name] transaction.
359+
const result = await buyerArio.buyRecord({
360+
name,
361+
type: 'lease',
362+
years: 1,
363+
});
364+
assert.ok(result.id, 'spawn-and-buy buyRecord must return a tx id');
365+
const spawnedProcessId = result.result?.processId as string | undefined;
366+
assert.ok(
367+
spawnedProcessId,
368+
'buyRecord must surface the spawned ANT processId',
369+
);
370+
371+
// The record points at the freshly-minted ANT — NOT the "no ANT" sentinel.
372+
const record = await buyerArio.getArNSRecord({ name });
373+
assert.equal(record.processId, spawnedProcessId);
374+
assert.notEqual(record.processId, '11111111111111111111111111111111');
375+
assert.equal(record.type, 'lease');
376+
assert.ok(record.purchasePrice > 0, 'purchasePrice must be > 0');
377+
378+
// The spawned asset is a real MPL Core NFT owned by the buyer.
379+
const rpc = createSolanaRpc(RPC_URL!);
380+
const owner = await fetchMplCoreOwner(rpc, address(spawnedProcessId!), {
381+
commitment: 'confirmed',
382+
});
383+
assert.equal(owner?.toLowerCase(), buyer.signer.address.toLowerCase());
384+
385+
// The deferred follow-up tx bootstrapped the buyer's ACL (owner + controller).
386+
const registry = new SolanaANTRegistryReadable({
387+
rpc,
388+
antProgramId: address(ANT_ID!),
389+
});
390+
const acl = await registry.accessControlList({
391+
address: buyer.signer.address as string,
392+
});
393+
assert.ok(
394+
acl.Owned.includes(spawnedProcessId!),
395+
'spawned ANT must appear in the owner ACL after the follow-up tx',
396+
);
397+
assert.ok(
398+
acl.Controlled.includes(spawnedProcessId!),
399+
'spawned ANT must appear in the controller ACL after the follow-up tx',
400+
);
401+
});
402+
345403
it('buyRecord({ fundFrom: "stakes", fundAsOperator: false }) deducts from delegation', async () => {
346404
// Fresh delegator signer
347405
const delegator = await freshSigner(scratch, 'delegator');

src/solana/io-writeable.ts

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
AccountRole,
3939
type Address,
4040
type Instruction,
41+
type KeyPairSigner,
4142
address,
4243
appendTransactionMessageInstructions,
4344
compileTransaction,
@@ -204,6 +205,7 @@ import {
204205
getUpdateObserverAddressInstructionAsync,
205206
} from '@ar.io/solana-contracts/gar';
206207
import { getTransferCheckedInstruction } from '@solana-program/token';
208+
import { SolanaANTRegistryWriteable } from './ant-registry-writeable.js';
207209
import { ARIO_ANT_PROGRAM_ID, TOKEN_DECIMALS } from './constants.js';
208210
import { SolanaARIOReadable } from './io-readable.js';
209211
import {
@@ -239,10 +241,13 @@ import {
239241
} from './predict-prescribed-observers.js';
240242
import {
241243
DEFAULT_COMPUTE_UNIT_LIMIT,
244+
MAX_TX_SIZE_BYTES,
245+
estimateCompiledTxSize,
242246
reclaimLookupTablesForSigner,
243247
sendAndConfirm,
244248
sendWithEphemeralLookupTable,
245249
} from './send.js';
250+
import { buildSpawnAntInstructions } from './spawn-ant.js';
246251
import type {
247252
SolanaRpcSubscriptions,
248253
SolanaSigner,
@@ -430,6 +435,47 @@ export function encodeReportTxId(reportTxId: string | undefined): Buffer {
430435
* `compound-crank.test.ts` asserts this invariant against the live constant.
431436
*/
432437
export const MAX_COMPOUND_BATCH = 6;
438+
/**
439+
* CU ceiling for the atomic spawn-and-buy tx (`[CreateV1, initialize,
440+
* buy_name]`). buy_name CPIs into MPL Core `UpdatePluginV1` on top of the MPL
441+
* Core mint + ario-ant initialize, so it needs more headroom than a plain
442+
* buy (`DEFAULT_COMPUTE_UNIT_LIMIT`). Keypair signers auto-size below this from
443+
* a pre-send simulation; message-modifying wallets keep this generous ceiling.
444+
*/
445+
const SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT = 800_000;
446+
/**
447+
* Collect the account addresses in `instructions` that are safe to serve from
448+
* an Address Lookup Table: every account meta EXCEPT signers (which must remain
449+
* in the static keys) and the invoked top-level program ids (a program invoked
450+
* by an instruction cannot be loaded from an ALT). `alwaysInline` pins extra
451+
* addresses static — the fee payer and any bundled mint signer. CPI-target
452+
* programs that appear only as account metas (e.g. system, token) ARE eligible.
453+
* Deduped; order-independent.
454+
*/
455+
function altEligibleAddresses(
456+
instructions: Instruction[],
457+
alwaysInline: Address[],
458+
): Address[] {
459+
const inline = new Set<string>(alwaysInline as string[]);
460+
for (const ix of instructions) {
461+
inline.add(ix.programAddress);
462+
for (const acc of ix.accounts ?? []) {
463+
if (
464+
acc.role === AccountRole.READONLY_SIGNER ||
465+
acc.role === AccountRole.WRITABLE_SIGNER
466+
) {
467+
inline.add(acc.address);
468+
}
469+
}
470+
}
471+
const eligible = new Set<Address>();
472+
for (const ix of instructions) {
473+
for (const acc of ix.accounts ?? []) {
474+
if (!inline.has(acc.address)) eligible.add(acc.address);
475+
}
476+
}
477+
return [...eligible];
478+
}
433479
/** Observation PDAs closed per tx before close_epoch (each ix carries Epoch +
434480
* Observation + payer + system accounts — keep well under the tx account cap). */
435481
const MAX_CLOSE_OBSERVATION_BATCH = 8;
@@ -579,6 +625,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
579625
protected async sendTransaction(
580626
instructions: Instruction[],
581627
computeUnitLimit = DEFAULT_COMPUTE_UNIT_LIMIT,
628+
extraSigners: KeyPairSigner[] = [],
582629
): Promise<string> {
583630
return sendAndConfirm({
584631
rpc: this.rpc,
@@ -587,6 +634,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
587634
instructions,
588635
commitment: this.commitment,
589636
computeUnitLimit,
637+
extraSigners,
590638
});
591639
}
592640

@@ -1449,9 +1497,31 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
14491497
arnsConfig.mint,
14501498
this.signer.address,
14511499
);
1452-
const antPubkey = address(
1453-
params.processId ?? ('11111111111111111111111111111111' as Address),
1454-
);
1500+
1501+
// When no ANT (`processId`) is supplied, atomically spawn a fresh ANT and
1502+
// assign the name to it in the SAME transaction. `buy_name` CPIs into the
1503+
// new asset's Attributes plugin to write the ArNS traits — instructions
1504+
// execute in order, so the asset (minted by the prepended `CreateV1`)
1505+
// exists by the time `buy_name` runs. The owner's ACL registry bootstrap +
1506+
// trait sync are deferred to a follow-up tx to keep this one under the
1507+
// 1232-byte transaction-size limit (neither needs to be atomic with the
1508+
// purchase). See `_bootstrapSpawnedAntAcl`.
1509+
let spawnIxs: Instruction[] = [];
1510+
let mintSigner: KeyPairSigner | undefined;
1511+
let antPubkey: Address;
1512+
if (params.processId === undefined) {
1513+
const spawn = await buildSpawnAntInstructions({
1514+
signer: this.signer,
1515+
state: { name: params.name },
1516+
antProgramId: this.antProgram,
1517+
});
1518+
spawnIxs = spawn.instructions;
1519+
mintSigner = spawn.mintSigner;
1520+
antPubkey = spawn.mint;
1521+
} else {
1522+
antPubkey = address(params.processId);
1523+
}
1524+
14551525
const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
14561526
const [reservedNameCheck] = await getReservedNamePDA(
14571527
params.name,
@@ -1583,6 +1653,56 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
15831653
);
15841654
}
15851655

1656+
// Spawn-and-buy: prepend `[CreateV1, initialize]` and attach the mint
1657+
// signer. We DON'T bundle `sync_attributes` here — the asset doesn't exist
1658+
// on-chain at SDK build time (it's minted in THIS tx), so the owner check
1659+
// in `_buildSyncAttributesIxIfOwner` would 404. The ACL bootstrap + trait
1660+
// sync run in a separate follow-up tx (`buy_name` already populated the
1661+
// asset's traits via CPI, so the deferred sync just mirrors them into the
1662+
// ANT's on-chain record).
1663+
if (mintSigner !== undefined) {
1664+
const spawnAndBuyIxs = [...spawnIxs, ix];
1665+
// Balance/credit-funded spawn-and-buy fits inline (~1.1 KB) and lands in
1666+
// ONE signature. But a multi-source funding plan appends per-source
1667+
// remaining accounts to `buy_name` (~33 bytes each) and can blow past
1668+
// Solana's 1232-byte limit. When that happens, route the whole
1669+
// spawn-and-buy through an ephemeral Address Lookup Table (create →
1670+
// extend → compressed v0 tx), compressing every non-signer,
1671+
// non-invoked-program account. The mint stays inline (it's a signer).
1672+
const inlineSize = estimateCompiledTxSize({
1673+
signer: this.signer,
1674+
instructions: spawnAndBuyIxs,
1675+
extraSigners: [mintSigner],
1676+
computeUnitLimit: SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1677+
});
1678+
let sig: string;
1679+
if (inlineSize <= MAX_TX_SIZE_BYTES) {
1680+
sig = await this.sendTransaction(
1681+
spawnAndBuyIxs,
1682+
SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1683+
[mintSigner],
1684+
);
1685+
} else {
1686+
sig = await sendWithEphemeralLookupTable({
1687+
rpc: this.rpc,
1688+
rpcSubscriptions: this.rpcSubscriptions,
1689+
signer: this.signer,
1690+
instructions: spawnAndBuyIxs,
1691+
lookupAddresses: altEligibleAddresses(spawnAndBuyIxs, [
1692+
this.signer.address,
1693+
mintSigner.address,
1694+
]),
1695+
commitment: this.commitment,
1696+
computeUnitLimit: SPAWN_AND_BUY_COMPUTE_UNIT_LIMIT,
1697+
extraSigners: [mintSigner],
1698+
});
1699+
}
1700+
await this._bootstrapSpawnedAntAcl(antPubkey, params.name);
1701+
// Surface the freshly-minted ANT's id so callers don't have to re-fetch
1702+
// the record to discover which asset the name was assigned to.
1703+
return { id: sig, result: { processId: antPubkey as string } };
1704+
}
1705+
15861706
// Sprint 4 / ADR-016: bundle `ant.sync_attributes` IFF the buyer
15871707
// owns the ANT (preserves BD-096 — non-holder buys defer the trait
15881708
// sync to a later `syncAttributes()` call by the actual owner).
@@ -1597,6 +1717,74 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
15971717
return { id: sig };
15981718
}
15991719

1720+
/**
1721+
* Post-spawn housekeeping for the atomic spawn-and-buy path: bootstrap the
1722+
* new owner's paginated ACL registry entries (so "ANTs I own / control"
1723+
* lookups resolve) and sync the ANT's on-chain record from the asset's
1724+
* Attributes plugin (which `buy_name` populated via CPI). Sent as a SEPARATE
1725+
* transaction because bundling it with `[create, initialize, buy_name]`
1726+
* would overflow the 1232-byte transaction-size limit — and neither step
1727+
* needs to be atomic with the purchase.
1728+
*
1729+
* Best-effort: the name purchase + ANT mint already confirmed in the prior
1730+
* tx, so a failure here is logged rather than thrown. The owner can reconcile
1731+
* later via the sync-ACL API and `syncAttributes()`.
1732+
*/
1733+
private async _bootstrapSpawnedAntAcl(
1734+
asset: Address,
1735+
name: string,
1736+
): Promise<void> {
1737+
// Record the owner/controller ACL entries FIRST, in their OWN transaction.
1738+
// This is the ownership-critical step — the app-side drift detection treats
1739+
// a missing ACL owner entry as "needs ownership sync". It must NOT be
1740+
// bundled with `sync_attributes`: if that instruction reverts (e.g. an ANT
1741+
// program build whose `sync_attributes` enforces an `ant_authority` PDA the
1742+
// client doesn't yet derive), an atomic bundle would roll the ACL records
1743+
// back too, silently un-recording ownership.
1744+
try {
1745+
const registry = new SolanaANTRegistryWriteable({
1746+
rpc: this.rpc,
1747+
signer: this.signer,
1748+
commitment: this.commitment,
1749+
antProgramId: this.antProgram,
1750+
logger: this.logger,
1751+
});
1752+
const aclIxs = await registry.bootstrapOwnerOnSpawn({
1753+
owner: this.signer.address,
1754+
asset,
1755+
});
1756+
if (aclIxs.length > 0) {
1757+
await this.sendTransaction(aclIxs);
1758+
}
1759+
} catch (err) {
1760+
this.logger.warn(
1761+
`[buyRecord] spawned ANT ${asset} for "${name}" but ACL owner/controller ` +
1762+
`bootstrap failed; reconcile via the sync-ACL API. Purchase + mint ` +
1763+
`already confirmed.`,
1764+
err,
1765+
);
1766+
}
1767+
1768+
// Mirror the asset's Attributes plugin (populated by `buy_name`'s CPI) into
1769+
// the ANT's on-chain record. Best-effort and INDEPENDENT of the ACL step
1770+
// above — a failure here (e.g. a program/client version skew on
1771+
// `sync_attributes`) must not undo the ownership records. Reconcilable later
1772+
// via the public `syncAttributes()`.
1773+
try {
1774+
const syncIx = await this._buildSyncAttributesIxIfOwner(name, asset);
1775+
if (syncIx) {
1776+
await this.sendTransaction([syncIx]);
1777+
}
1778+
} catch (err) {
1779+
this.logger.warn(
1780+
`[buyRecord] spawned ANT ${asset} for "${name}": ACL recorded, but ` +
1781+
`sync_attributes failed; ANT record traits will reconcile on a later ` +
1782+
`syncAttributes() call.`,
1783+
err,
1784+
);
1785+
}
1786+
}
1787+
16001788
/**
16011789
* Resolve a `FundingPlan` for a fee-paying ArNS ix. When `params.sources`
16021790
* is set, use it verbatim (caller-supplied plan); otherwise discover the
@@ -3810,7 +3998,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
38103998
rpc: this.rpc,
38113999
rpcSubscriptions: this.rpcSubscriptions,
38124000
signer: this.signer,
3813-
instruction: fullIx,
4001+
instructions: [fullIx],
38144002
lookupAddresses: remaining.map((a) => a.address),
38154003
commitment: this.commitment,
38164004
computeUnitLimit: 1_000_000,

0 commit comments

Comments
 (0)