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 backDesign 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
| Vector | Status |
|---|---|
| Charge redirected to another recipient | Mitigated: recipient fixed in the PDA at creation |
| Executing early | Mitigated: on-chain Clock check against execute_at (5-minute tolerance) |
| Executing too many times | Mitigated: max_executions and status checked before each run |
| Cancelling someone else's charge | Mitigated: authority constraint on cancel |
| Paying from the wrong token account | Mitigated: owner checked against the stored authority |
| Client-supplied timestamp | Fixed. 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 account | Fixed. 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.