Skip to main content

Phase 4: Non-Custodial Solana with Anchor PDAs

· 5 min read
OrcaRail
Crypto payment rails for web2 apps

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_link program owns a PDA per payment link, derived from seeds = [b"pl", link_id]. No keypair, no seed, no sweep.
  • subscription program 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_splitter logic 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).

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. Emits PaymentReceived { 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_at
  • clock.unix_timestamp > last_charged_at // monotonic
  • amount_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.generateAddressForNetwork stops deriving keypairs and returns findProgramAddressSync(["pl", linkId], programId) instead. No encryptedSeed row 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 charge transaction, sign with the keeper (gasless path via sponsor tx, similar to Phase 2 paymaster).
  • withdrawals/services/solana-withdrawal-transfer.service.ts is 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.
  • SolanaRelayBridgeExecutorService in 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: charge double-checks delegated_amount before transferring; failed checks emit ChargeSkipped(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_link supports both, with explicit expected_mint per 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_link program with all instructions + tests
  • subscription program 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