Skip to content

Commit b93cd1a

Browse files
fix(payments): split Solana subscription init-authority into its own tx
First-time subscribers failed on-chain with STALE_SUBSCRIPTION_AUTHORITY (custom error 136): buildSubscribeTxUnsignedBase64 bundled init-authority + subscribe in one transaction and passed expectedInitId = getSlot() captured at BUILD time, but the program sets the authority's init_id = Clock::slot at EXECUTION time, so the two never matched. (Earlier script-verified runs only passed because the authority was pre-created in a separate tx.) - Add buildInitAuthorityTxUnsignedBase64() — init-only unsigned tx; returns null when the authority already exists. - buildSubscribeTxUnsignedBase64 no longer bundles init; it throws if the authority is missing and always reads the stored init_id. - /api/payments/solana/subscribe-tx is now two-step: returns {step:'init'} when the authority is missing, {step:'subscribe'} once it exists. The wallet signs + confirms init, then re-requests the subscribe tx. Verified end-to-end on devnet with a real Phantom wallet: init -> subscribe -> first pull -> 80/20 split on-chain, subscription active. Known limitation: the mobile Solana-Pay QR is one-scan/one-tx and can't do the two-step; first-time subscribers via QR need a desktop/extension path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba0bbdc commit b93cd1a

3 files changed

Lines changed: 176 additions & 51 deletions

File tree

app/api/payments/solana/subscribe-tx/route.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { getBase58Encoder } from '@solana/kit'
2929
import {
3030
ensurePlanOnChain,
3131
buildSubscribeTxUnsignedBase64,
32+
buildInitAuthorityTxUnsignedBase64,
3233
} from '@/lib/payments/solana-subscriptions'
3334

3435
export const runtime = 'nodejs'
@@ -68,7 +69,7 @@ export async function POST(req: NextRequest) {
6869
}
6970
// Validate the subscriber pubkey.
7071
try {
71-
// eslint-disable-next-line no-new
72+
7273
new PublicKey(account)
7374
} catch {
7475
return NextResponse.json({ error: 'Invalid account' }, { status: 400 })
@@ -149,7 +150,30 @@ export async function POST(req: NextRequest) {
149150
.eq('transaction_id', tx.transaction_id)
150151
.is('provider_metadata', null)
151152

152-
// 1. Ensure the on-chain plan exists (idempotent; puller = merchant/owner).
153+
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'LMS'
154+
155+
// STEP 1 (first-time subscribers only): the SubscriptionAuthority must be
156+
// created in its OWN confirmed transaction before subscribe — a bundled
157+
// init+subscribe always fails with STALE_SUBSCRIPTION_AUTHORITY (136),
158+
// because the authority's init_id is the execution slot and subscribe can't
159+
// pass a matching value at build time. If the authority is missing, return
160+
// the init tx; the wallet signs + confirms it, then calls again for subscribe.
161+
const initTx = await buildInitAuthorityTxUnsignedBase64({
162+
rpcUrl,
163+
subscriber: account,
164+
mint,
165+
})
166+
if (initTx) {
167+
return NextResponse.json({
168+
transaction: initTx,
169+
step: 'init',
170+
message: `${appName} — initialize subscription wallet`,
171+
})
172+
}
173+
174+
// STEP 2: authority exists. Ensure the on-chain plan exists (idempotent;
175+
// puller = merchant/owner), then build the unsigned SUBSCRIBE tx carrying
176+
// our reference pubkey so findReference can locate it.
153177
await ensurePlanOnChain({
154178
rpcUrl,
155179
pullerSecretKeyBase58: pullerSecret,
@@ -161,8 +185,6 @@ export async function POST(req: NextRequest) {
161185
pullers: [merchant],
162186
})
163187

164-
// 2. Build the unsigned SUBSCRIBE tx (init authority + subscribe), with our
165-
// reference pubkey attached so findReference can locate it.
166188
const transaction = await buildSubscribeTxUnsignedBase64({
167189
rpcUrl,
168190
subscriber: account,
@@ -172,9 +194,9 @@ export async function POST(req: NextRequest) {
172194
reference: referencePubkey,
173195
})
174196

175-
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'LMS'
176197
return NextResponse.json({
177198
transaction,
199+
step: 'subscribe',
178200
message: `${appName} — subscribe ${tx.transaction_id}`,
179201
})
180202
} catch (error) {

lib/payments/solana-subscriptions.ts

Lines changed: 85 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -578,54 +578,18 @@ export async function buildSubscribeTxUnsignedBase64(p: {
578578
// expected_subscription_authority_init_id == authority.init_id
579579
// (else SUBSCRIPTIONS_ERROR__STALE_SUBSCRIPTION_AUTHORITY = 136).
580580
//
581-
// - If the authority already exists, read its stored init_id.
582-
// - If we bundle init + subscribe in this same tx, the init instruction will
583-
// set init_id to the slot in which the tx executes, so the matching value
584-
// is the current slot. Query it from the RPC (constant within a slot).
585-
let expectedInitId: bigint;
586-
581+
// init_id therefore equals the slot in which the init tx EXECUTES, which we
582+
// cannot predict at build time. Bundling init + subscribe in one tx is thus
583+
// impossible: the build-time slot we'd pass never matches the execution slot,
584+
// so subscribe fails with 136. The authority MUST be created in its own,
585+
// already-confirmed transaction (buildInitAuthorityTxUnsignedBase64) before
586+
// this runs; we then read its stored init_id.
587587
if (!maybeAuthority.exists) {
588-
let ataAddr: string;
589-
if (p.userAta) {
590-
ataAddr = p.userAta;
591-
} else {
592-
const spl = await import("@solana/spl-token").catch(() => null);
593-
const web3 = await import("@solana/web3.js").catch(() => null);
594-
if (spl && web3 && "getAssociatedTokenAddressSync" in spl) {
595-
const { getAssociatedTokenAddressSync } =
596-
spl as typeof import("@solana/spl-token");
597-
const { PublicKey } = web3 as typeof import("@solana/web3.js");
598-
ataAddr = getAssociatedTokenAddressSync(
599-
new PublicKey(p.mint),
600-
new PublicKey(p.subscriber)
601-
).toBase58();
602-
} else {
603-
throw new Error(
604-
"buildSubscribeTxUnsignedBase64: userAta is required when @solana/spl-token v1 is unavailable"
605-
);
606-
}
607-
}
608-
609-
const SPL_TOKEN_PROGRAM_ADDR =
610-
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
611-
const initIx = getInitSubscriptionAuthorityInstruction({
612-
owner: subscriberSigner,
613-
subscriptionAuthority: subAuthorityPda,
614-
tokenMint: addr(p.mint) as Address,
615-
userAta: addr(ataAddr) as Address,
616-
tokenProgram: addr(
617-
p.tokenProgram ?? SPL_TOKEN_PROGRAM_ADDR
618-
) as Address,
619-
});
620-
instructions.push(initIx);
621-
622-
// init_id will equal Clock::slot at execution → use the current slot.
623-
const currentSlot = await rpc.getSlot().send();
624-
expectedInitId = BigInt(currentSlot);
625-
} else {
626-
// Authority already exists — match its stored init_id exactly.
627-
expectedInitId = maybeAuthority.data.initId;
588+
throw new Error(
589+
"buildSubscribeTxUnsignedBase64: SubscriptionAuthority does not exist for this subscriber+mint — initialize it first in a separate, confirmed transaction"
590+
);
628591
}
592+
const expectedInitId: bigint = maybeAuthority.data.initId;
629593

630594
const subscribeData: SubscribeDataArgs = {
631595
planId: p.planId,
@@ -683,6 +647,81 @@ export async function buildSubscribeTxUnsignedBase64(p: {
683647
return getBase64EncodedWireTransaction(compiledTx) as string;
684648
}
685649

650+
/**
651+
* Builds the unsigned, init-SubscriptionAuthority transaction for a subscriber.
652+
*
653+
* This MUST be a separate transaction from subscribe (see the 136 note in
654+
* buildSubscribeTxUnsignedBase64): the authority's init_id is the execution
655+
* slot, so subscribe can only pass a matching init_id once the authority is
656+
* already on-chain. The wallet signs + submits this, waits for confirmation,
657+
* then requests the subscribe tx.
658+
*
659+
* Returns `null` if the authority already exists (nothing to do).
660+
*/
661+
export async function buildInitAuthorityTxUnsignedBase64(p: {
662+
rpcUrl: string;
663+
/** Subscriber's base58 public key — NOT a secret key. */
664+
subscriber: string;
665+
mint: string;
666+
tokenProgram?: string;
667+
/** Optional: subscriber's ATA for the mint. */
668+
userAta?: string;
669+
}): Promise<string | null> {
670+
const rpc = makeRpc(p.rpcUrl);
671+
const subscriberAddr = addr(p.subscriber) as Address;
672+
const subscriberSigner = createNoopSigner(subscriberAddr);
673+
674+
const maybeAuthority = await fetchMaybeSubscriptionAuthorityFromSeeds(rpc, {
675+
user: subscriberAddr,
676+
tokenMint: addr(p.mint) as Address,
677+
});
678+
if (maybeAuthority.exists) return null; // already initialized — skip
679+
680+
const [subAuthorityPda] = await findSubscriptionAuthorityPda({
681+
user: subscriberAddr,
682+
tokenMint: addr(p.mint) as Address,
683+
});
684+
685+
let ataAddr: string;
686+
if (p.userAta) {
687+
ataAddr = p.userAta;
688+
} else {
689+
const spl = await import("@solana/spl-token").catch(() => null);
690+
const web3 = await import("@solana/web3.js").catch(() => null);
691+
if (spl && web3 && "getAssociatedTokenAddressSync" in spl) {
692+
const { getAssociatedTokenAddressSync } =
693+
spl as typeof import("@solana/spl-token");
694+
const { PublicKey } = web3 as typeof import("@solana/web3.js");
695+
ataAddr = getAssociatedTokenAddressSync(
696+
new PublicKey(p.mint),
697+
new PublicKey(p.subscriber)
698+
).toBase58();
699+
} else {
700+
throw new Error(
701+
"buildInitAuthorityTxUnsignedBase64: userAta is required when @solana/spl-token v1 is unavailable"
702+
);
703+
}
704+
}
705+
706+
const SPL_TOKEN_PROGRAM_ADDR = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
707+
const initIx = getInitSubscriptionAuthorityInstruction({
708+
owner: subscriberSigner,
709+
subscriptionAuthority: subAuthorityPda,
710+
tokenMint: addr(p.mint) as Address,
711+
userAta: addr(ataAddr) as Address,
712+
tokenProgram: addr(p.tokenProgram ?? SPL_TOKEN_PROGRAM_ADDR) as Address,
713+
});
714+
715+
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
716+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
717+
let msg: any = createTransactionMessage({ version: 0 });
718+
msg = setTransactionMessageFeePayer(subscriberAddr, msg);
719+
msg = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, msg);
720+
msg = appendTransactionMessageInstruction(initIx as never, msg);
721+
const compiledTx = compileTransaction(msg);
722+
return getBase64EncodedWireTransaction(compiledTx) as string;
723+
}
724+
686725
/**
687726
* Idempotent: ensures a plan exists on-chain.
688727
*

scripts/devnet-fund-wallet.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Fund an arbitrary devnet wallet for the real-wallet subscription test:
3+
* - transfers a little SOL (fees + account rent) from the funder, and
4+
* - creates the wallet's token account + mints test "USDC" tokens to it.
5+
*
6+
* Avoids the public faucet (rate-limited) by using the already-funded funder
7+
* keypair (scripts/.devnet-payer.json) and the test mint's authority.
8+
*
9+
* Usage: node scripts/devnet-fund-wallet.mjs <wallet_address> [tokens] [sol]
10+
* tokens default 5, sol default 0.05
11+
*/
12+
13+
import {
14+
Connection, Keypair, LAMPORTS_PER_SOL, SystemProgram, Transaction,
15+
sendAndConfirmTransaction, PublicKey,
16+
} from '@solana/web3.js'
17+
import { getOrCreateAssociatedTokenAccount, mintTo } from '@solana/spl-token'
18+
import { readFileSync, existsSync } from 'node:fs'
19+
import { fileURLToPath } from 'node:url'
20+
import { dirname, join } from 'node:path'
21+
22+
const RPC = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com'
23+
const here = dirname(fileURLToPath(import.meta.url))
24+
const p = (f) => join(here, f)
25+
26+
const dest = process.argv[2]
27+
const tokens = Number(process.argv[3] || '5')
28+
const sol = Number(process.argv[4] || '0.05')
29+
if (!dest) { console.error('usage: node scripts/devnet-fund-wallet.mjs <wallet_address> [tokens] [sol]'); process.exit(1) }
30+
31+
let destPk
32+
try { destPk = new PublicKey(dest) } catch { console.error('invalid wallet address:', dest); process.exit(1) }
33+
34+
if (!existsSync(p('.devnet-payer.json'))) { console.error('scripts/.devnet-payer.json not found'); process.exit(1) }
35+
if (!existsSync(p('.devnet-mint.json'))) { console.error('scripts/.devnet-mint.json not found — run devnet-subs-setup.mjs first'); process.exit(1) }
36+
37+
const conn = new Connection(RPC, 'confirmed')
38+
const funder = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(p('.devnet-payer.json'), 'utf8'))))
39+
const mint = new PublicKey(JSON.parse(readFileSync(p('.devnet-mint.json'), 'utf8')).mint)
40+
41+
console.log('funder:', funder.publicKey.toBase58())
42+
console.log('mint :', mint.toBase58())
43+
console.log('dest :', destPk.toBase58())
44+
45+
// 1. SOL for fees + rent.
46+
const need = Math.round(sol * LAMPORTS_PER_SOL)
47+
const have = await conn.getBalance(destPk)
48+
if (have < need) {
49+
const tx = new Transaction().add(
50+
SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: destPk, lamports: need - have }),
51+
)
52+
const sig = await sendAndConfirmTransaction(conn, tx, [funder])
53+
console.log(`sent ${(need - have) / LAMPORTS_PER_SOL} SOL ✓ (${sig})`)
54+
} else {
55+
console.log('already has >= requested SOL, skipping transfer')
56+
}
57+
58+
// 2. Token account + mint test tokens (6 decimals).
59+
const ata = await getOrCreateAssociatedTokenAccount(conn, funder, mint, destPk)
60+
await mintTo(conn, funder, mint, ata.address, funder, Math.round(tokens * 1_000_000))
61+
const bal = await conn.getTokenAccountBalance(ata.address)
62+
console.log(`token ATA: ${ata.address.toBase58()}`)
63+
console.log(`minted ${tokens} tokens ✓ — balance now ${bal.value.uiAmountString}`)
64+
console.log('\nWallet funded ✓ — open the desktop test page and subscribe.')

0 commit comments

Comments
 (0)