Lucas de Almeida

Blinkpay

Payment links on Solana: create a request, share it, get paid in SOL or USDC.

Context
Self-study, 2025
Stack
Anchor, Next.js
Links
codedemo

The problem

Merchants want two things a plain transfer doesn't give them: a payment request the customer can open and pay, and a charge that runs again next month without anyone remembering to send it.

How it works

One Anchor program with two account types:

  • Payment requests: a PDA describing amount, token and recipient that anyone can fulfil.
  • Scheduled charges: one-off or recurring transfers in SOL or PYUSD that become executable at execute_at, with an optional interval and a maximum number of runs.
payer ── create_scheduled_charge(amount, recipient, execute_at, interval, max) ──► PDA
anyone ── execute_scheduled_charge (once due) ──► transfer, then schedule the next run
payer ── cancel_scheduled_charge ──► account closed, rent back

Design decisions

  • Permissionless execution. Anyone can trigger a due charge, so the merchant doesn't need to run a crank; the money can only ever go to the recipient fixed at creation.
  • State before transfer. The execution count and next run time are written before the CPI, following checks-effects-interactions.
  • Only the creator cancels, checked against the stored authority.

What could go wrong

VectorStatus
Charge redirected to another recipientMitigated: recipient fixed in the PDA at creation
Executing earlyMitigated: on-chain Clock check against execute_at (5-minute tolerance)
Executing too many timesMitigated: max_executions and status checked before each run
Cancelling someone else's chargeMitigated: authority constraint on cancel
Paying from the wrong token accountMitigated: owner checked against the stored authority
Client-supplied timestampFixed. created_at and the "is this in the future" check now read the Clock sysvar; the old argument is only a PDA nonce
SPL payment sent to the wrong accountFixed. pay_request didn't check the recipient token account, so a payer could mark a request paid while sending tokens to themselves. Both token accounts are now checked for owner and mint, with a regression test

What I learned

Two of the bugs above were in the program from the start and the tests still passed, because the tests made the same assumptions as the code: one passed a fake "current time", the other never tried paying into the wrong account. Writing the test from the attacker's side is what caught them.

Where it stands

A self-study project, redeployed to Solana devnet with the fixes above.