TreasuryStandard program
Per-agent PDA wallets with spend limits, streaming budgets, allowlists, and Token-2022-aware transfers.
Owner: anchor-engineer Depends on: 03 Blocks: 07 References: backend PDF §2.1 (CU budgets: ~80K fund / ~60K stream-init / ~40K withdraw), §2.3 (full spec: PDA wallet, spending limits, streaming, Jupiter CPI), §2.6 (7-day timelock), §5.1 (Re-entrancy, Integer Safety, Token Safety, Oracle Safety)
Goal
A per-agent PDA wallet that enforces spending limits at the instruction level and supports time-locked payment streams with Jupiter auto-swap at settlement. Client deposits, agent earns, limits are checked by the program — not the UI.
State
TreasuryGlobal PDA — singleton
- Seeds:
[b"treasury_global"] - Fields:
authority: Pubkeyagent_registry: Pubkey— program idjupiter_program: Pubkey— Jupiter aggregator programallowed_mints: Pubkey— pointer toAllowedMintsPDA (simple whitelist)max_stream_duration: i64— default 30 daysdefault_daily_limit: u64— governance-configurable guardrail; treasuries can override only withinmax_daily_limitmax_daily_limit: u64paused: boolbump: u8
AgentTreasury PDA (per agent, per backend §2.3)
- Seeds:
[b"treasury", agent_did.as_ref()] - Fields:
agent_did: [u8; 32]operator: Pubkeydaily_spend_limit: u64(USDC lamports)per_tx_limit: u64weekly_limit: u64spent_today: u64— resets at UTC midnightspent_this_week: u64— resets Monday UTClast_reset_day: i64— unix day numberlast_reset_week: i64— ISO week numberstreaming_active: boolstream_counterparty: Option<Pubkey>stream_rate_per_sec: u64bump: u8
TreasuryVault PDA — per agent, per mint, SPL token account
- Seeds:
[b"vault", agent_did.as_ref(), mint.as_ref()] - Owned by program; holds USDC/SOL-wrapped/SAEP balances.
PaymentStream PDA
- Seeds:
[b"stream", agent_did.as_ref(), counterparty.as_ref(), stream_nonce.as_ref()] - Fields:
agent_did: [u8; 32]client: Pubkey— counterparty funding the streampayer_mint: Pubkeypayout_mint: Pubkey— what the agent receives (may differ → Jupiter swap atwithdraw_earned)rate_per_sec: u64— inpayer_mintunitsstart_time: i64max_duration: i64— rejects> global.max_stream_durationdeposit_total: u64—rate * max_durationwithdrawn: u64— watermark in payer-mint unitsescrow_bump: u8status: StreamStatus—Active | Closed
StreamEscrow PDA — SPL token account per stream
- Seeds:
[b"stream_escrow", stream.key().as_ref()]
AllowedMints PDA — simple Pubkey array (max 16)
- Seeds:
[b"allowed_mints"] - Populated by governance. M1 seeds: USDC-dev, SOL-wrapped, SAEP-mock.
Instructions
init_global(...) — one-shot, deployer-signed.
init_treasury(agent_did, daily_spend_limit, per_tx_limit, weekly_limit)
- Signers:
operator - Validation:
- CPI-read
AgentRegistry::AgentAccountfor(operator, agent_id)whereagent.did == agent_did,status == Active daily_spend_limit <= global.max_daily_limitper_tx_limit <= daily_spend_limit <= weekly_limit!global.paused
- CPI-read
- Creates
AgentTreasury. No vaults yet — created lazily per mint. - Emits:
TreasuryCreated
fund_treasury(agent_did, mint, amount)
- Signers: any funder (permissionless deposit)
- Validation: mint in
AllowedMints,amount > 0, treasury exists. - Creates
TreasuryVaultfor(did, mint)if missing. Token-2022transfer_checkedfrom funder ATA to vault. - Emits:
TreasuryFunded { agent_did, mint, amount, funder } - CU target: 80k (§2.1)
withdraw(agent_did, mint, amount, destination)
- Signers:
operator - Validation:
- Treasury + vault exist,
amount <= vault.balance amount <= per_tx_limit- Apply rollover: if
today > last_reset_day,spent_today = 0; if new ISO week,spent_this_week = 0 checked_add(spent_today, amount) <= daily_spend_limitchecked_add(spent_this_week, amount) <= weekly_limit- State written before CPI (§5.1 re-entrancy)
- Treasury + vault exist,
- Token-2022
transfer_checkedvault →destination. - Emits:
TreasuryWithdraw - CU target: 40k
set_limits(agent_did, daily, per_tx, weekly)
- Signers:
operator. Same validation asinit_treasury.
init_stream(agent_did, client, payer_mint, payout_mint, rate_per_sec, max_duration, stream_nonce)
- Signers:
client - Validation:
max_duration <= global.max_stream_duration!treasury.streaming_active(M1: at most one concurrent stream per treasury; reviewer may relax)payer_mint,payout_mintboth inAllowedMintsrate_per_sec > 0
- Creates
PaymentStream,StreamEscrow. Transfersdeposit_total = rate * max_duration(checked_mul, overflow-safe) from client to escrow. - Sets
treasury.streaming_active = true,stream_counterparty = Some(client),stream_rate_per_sec = rate_per_sec. - Emits:
StreamInitialized - CU target: 60k
withdraw_earned(stream)
- Signers:
operator(agent side) - Validation: stream
Active,clock.now > stream.start_time. elapsed = min(now - start_time, max_duration)earned_total = checked_mul(rate_per_sec, elapsed)clamped todeposit_totalclaimable = earned_total - withdrawn; requireclaimable > 0- Writes
withdrawn = earned_totalBEFORE any CPI. - If
payer_mint == payout_mint: direct Token-2022 transfer escrow → agent vault. - Else: Jupiter CPI swap
payer_mint → payout_mintforclaimable, min-out =oracle_price * (1 - slippage_bps/10_000)using Pyth/Switchboard price feed (staleness < 60s, confidence < 1% per §5.1). Deposit post-swap into agent vault. - Emits:
StreamWithdrawn { claimable, swapped: bool }
close_stream(stream)
- Signers: either party (
clientoroperator) - Settles final
withdraw_earnedequivalent: agent gets unwithdrawn earned, client gets refund ofdeposit_total - earned_total. - Clears
treasury.streaming_active, counterparty, rate. - Emits:
StreamClosed
pay_task(agent_did, task_pda, mint, amount) — invoked by TaskMarket
- Signers: TaskMarket program PDA (verified by program id equality + expected PDA derivation)
- Bypasses daily/weekly limits (task escrow is already governance-bounded), but still subject to
per_tx_limit. Reviewer may tighten — see note. - Moves funds vault → task escrow. Used for "treasury pays agent's sub-task" flows in M2; in M1 this remains in place but TaskMarket's M1 flow uses client-funded escrow directly, not treasury-funded.
Governance setters
add_allowed_mint, remove_allowed_mint, set_default_daily_limit, set_max_daily_limit, set_max_stream_duration, set_paused, two-step transfer_authority / accept_authority.
Events
TreasuryCreated, TreasuryFunded, TreasuryWithdraw, LimitsUpdated, StreamInitialized, StreamWithdrawn, StreamClosed, AllowedMintAdded, AllowedMintRemoved, PausedSet.
Errors
Unauthorized, Paused, MintNotAllowed, LimitExceeded, InsufficientVault, StreamAlreadyActive, StreamNotActive, StreamAlreadyClosed, InvalidDuration, InvalidRate, OracleStale, OracleConfidenceTooWide, SwapSlippage, ArithmeticOverflow, CallerNotTaskMarket, AgentNotActive, InvalidLimits.
CU budget (§2.1 targets; M1 default, reviewer may tighten)
| Instruction | Target |
|---|---|
fund_treasury | 80k |
init_stream | 60k |
withdraw | 40k |
withdraw_earned (no swap) | 60k |
withdraw_earned (Jupiter swap) | 180k |
close_stream | 70k |
set_limits | 10k |
Invariants
sum(TreasuryVault balances across all mints) + escrowed stream balance == sum(fund - withdraw) ever(no value leak).spent_today <= daily_spend_limitandspent_this_week <= weekly_limitat every instruction boundary after rollover.withdrawn <= earned_at_now <= deposit_totalfor every stream.streaming_active == (stream_counterparty.is_some() && active PaymentStream exists)— bijection.- After
close_stream, agent+client receipts sum todeposit_total. - Jupiter swap never proceeds if oracle staleness > 60s or confidence > 1% (§5.1).
per_tx_limit <= daily_spend_limit <= weekly_limitalways.
Security checks (backend §5.1)
- Re-entrancy (critical here):
spent_today,spent_this_week,withdrawn,streaming_activeall updated before any Token-2022 or Jupiter CPI. No program re-enters TreasuryStandard from Jupiter (Jupiter has no callback into our program; still, we explicitly do not export any entrypoint reachable via account-mutation on swap). - Account Validation: all PDAs derived via Anchor
seeds + bump. Vault ownership asserted = program. TaskMarket caller verified by program-id + expected PDA. - Integer Safety:
checked_add/mul/subon all balance, spend, earned arithmetic.rate * max_durationchecked for overflow atinit_stream.rate * elapsedchecked atwithdraw_earned. - Oracle Safety: Jupiter min-out derived from Pyth/Switchboard with staleness + confidence +
status == Tradingchecks per §5.1. - Token Safety: Token-2022
transfer_checkedeverywhere. M1 whitelist excludes mints with TransferHook or ConfidentialTransfer extensions;AllowedMintsadd path validates extension set on insert. - Authorization: operator vs client vs TaskMarket-PDA distinguished on every handler. Limits cannot be raised above
global.max_daily_limit. - Slashing Safety: N/A here.
- Upgrade Safety: Squads 4-of-7, 7-day timelock (§2.6).
- Pause: blocks deposits, withdrawals, stream init;
close_streamremains available so paused state cannot trap funds.
Open questions for reviewer
- Whether
pay_taskshould exist at all in M1 or be deferred. Current spec keeps the handler but TaskMarket M1 does not call it. - Whether streams should allow top-up (M2 feature).
max_stream_durationdefault — 30 days is a guess; governance can tune.
Done-checklist
- Handlers + accounts compile, clippy clean
- Unit tests: limit rollover across UTC midnight, ISO week boundary
- Unit tests: overflow attempts on
rate * max_durationandrate * elapsedreject cleanly - Integration test: fund → withdraw within limits OK; exceeding per-tx rejects; daily cap rejects
- Integration test: init_stream → time warp 1h → withdraw_earned yields correct amount
- Integration test: withdraw_earned with mismatched mints triggers Jupiter CPI and respects slippage
- Integration test: stale oracle rejects swap
- Integration test: close_stream refunds client correctly
- Re-entrancy audit: every CPI call site inspected; state-before-CPI invariant documented per handler
- CU measurements logged in
reports/04-treasury-standard-anchor.md - IDL at
target/idl/treasury_standard.json - Security auditor pass against §5.1; findings closed