Phase 4: Non-Custodial Solana with Anchor PDAs
April 20, 2026 — Phase 4 brings the non-custodial model to Solana. Two Anchor programs replace the per-link HD-wallet + sweep flow: payment_link for one-off payments and subscription for SPL-delegate auto-charge. No mnemonic lives on our servers for new Solana links.
TL;DR
payment_linkprogram owns a PDA per payment link, derived fromseeds = [b"pl", link_id]. No keypair, no seed, no sweep.subscriptionprogram uses SPL delegate authority — the same primitive we already rely on in Auto-charge — as an explicit allowance granted by the payer.- Splits are done via CPI to the same
fee_splitterlogic we built on EVM, adapted to Solana accounts. - Sweep service api/src/withdrawals/services/solana-withdrawal-transfer.service.ts is bypassed for flagged links and removed in Phase 7.
What this phase changes
The "after" side replaces the HD seed in api/src/payments/wallet.service.ts (the m/44'/501'/i'/0' branch) with a pure deterministic PDA computation.
Programs introduced
New package contracts/solana/ (Anchor).
payment_link program
Accounts per link:
#[account]
pub struct PaymentLink {
pub link_id: [u8; 32],
pub expected_mint: Pubkey, // e.g. USDC on SOL
pub expected_amount: u64,
pub split: Split,
pub created_at: i64,
pub status: PaymentStatus,
}
Instructions:
initialize(link_id, expected_mint, expected_amount, split)— lazy; called on first deposit by the factory-style wrapper.pay(link_id, amount)— validates mint + amount, CPI-transfers into the PDA's ATA, then CPI-splits atomically. EmitsPaymentReceived { link_id, payer, amount }.refund(link_id)— multisig-gated, mirrors EVM refund.
PDA seeds: [b"pl", link_id]. PDA has no private key — it signs via invoke_signed.
subscription program
Leverages SPL Approve / Revoke for authority delegation. The payer signs spl_token::approve designating the program's PDA as delegate for up to cap tokens. Then:
pub fn charge(ctx: Context<Charge>, id: [u8; 32]) -> Result<()>
Can be called by anyone, checks:
clock.unix_timestamp >= next_charge_atclock.unix_timestamp > last_charged_at// monotonicamount_charged + period_amount <= cap- delegate authority still present on payer ATA
On pass, CPI spl_token::transfer_checked pulls from the payer's ATA (as delegate) and CPI-splits.
Events: SubscriptionCreated, Charged, ChargeSkipped, Canceled. Same shapes the EVM indexer produces, so api/src/webhooks is agnostic.
API changes
- The Solana branch of
WalletService.generateAddressForNetworkstops deriving keypairs and returnsfindProgramAddressSync(["pl", linkId], programId)instead. NoencryptedSeedrow written for new Solana links. - A new indexer service watches program logs (Helius webhooks or Triton streams) and emits existing webhook events.
- api/src/scheduler/processors/subscription-auto-charge.processor.ts adds a SOL branch: build a
chargetransaction, sign with the keeper (gasless path via sponsor tx, similar to Phase 2 paymaster). withdrawals/services/solana-withdrawal-transfer.service.tsis not called for flagged links.
Gas on Solana
Solana fees are small (typically 5000 lamports per sig, 0.000005 SOL). The keeper uses a hot-SOL balance with the same alerting pattern as Phase 3. Because Solana does not have an Alchemy-style paymaster, sponsored transactions use a simple co-signer pattern: the keeper signs as fee payer, payer does not need SOL at all.
Deprecations
- SOL HD derivation (
m/44'/501'/i'/0') for new links — not called when flag is on. solana-withdrawal-transfer.service.ts— bypassed for flagged links.SolanaRelayBridgeExecutorServicein api/src/withdrawals/services/bridge/solana-relay-bridge-executor.service.ts stays: bridging from SOL to EVM when the merchant lives on EVM still uses Relay, but the trigger moves to the program.
Migration plan
Same opt-in model as Phase 2:
- New payment links: PDA-addressed.
- New subscriptions: SPL-delegate-based.
- Existing active subscriptions: banner + email at next renewal to re-delegate to the new program.
- In-flight deposits on legacy addresses keep settling on the legacy path.
Risks and mitigations
- SPL delegate is overridable. Mitigation:
chargedouble-checksdelegated_amountbefore transferring; failed checks emitChargeSkipped(DelegateRevoked)and follow the same email template as the EVM allowance-revoked case. - Anchor upgrade authority. Mitigation: 3-of-5 Squads multisig owns the upgrade authority, timelocked. Matches the EVM Safe posture from Phase 0.
- Wrapped SOL vs native SOL. Mitigation:
payment_linksupports both, with explicitexpected_mintper link to avoid ambiguity. - Priority fee spikes. Mitigation: dynamic priority fee calculation in the keeper; fallback retry with higher priority after one slot.
- Audit. Mitigation: OtterSec or Neodyme audit of both programs before mainnet deploy.
Status checklist
- Anchor workspace
contracts/solana/scaffolded -
payment_linkprogram with all instructions + tests -
subscriptionprogram with all instructions + tests - Helius webhook indexer shipping
- Keeper SOL branch in
subscription-auto-charge.processor.ts - Audit report landed
- Mainnet deploy from Squads multisig
- Canary merchant cohort
What is next
Bitcoin. The hardest chain for non-custody, and the one where we have to be honest about the limits: Phase 5.
Reference docs: Phase 4 status · Contract reference · Architecture
