@@ -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' ;
206207import { getTransferCheckedInstruction } from '@solana-program/token' ;
208+ import { SolanaANTRegistryWriteable } from './ant-registry-writeable.js' ;
207209import { ARIO_ANT_PROGRAM_ID , TOKEN_DECIMALS } from './constants.js' ;
208210import { SolanaARIOReadable } from './io-readable.js' ;
209211import {
@@ -239,10 +241,13 @@ import {
239241} from './predict-prescribed-observers.js' ;
240242import {
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' ;
246251import 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 */
432437export 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). */
435481const 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