On this page
Why spend has no payee
An agent's request to pay names a mandate, an amount and an invoice, never a destination. The vault reads the payee from the mandate the owner signed, so an address that a hijacked prompt writes never reaches the vault as a destination.
The short answer#
The agent's request to pay, SpendRequest, has no field for an address. When the vault pays, it reads the payee from the mandate the owner signed, so whoever controls the agent can choose how much to spend under a mandate, within its limits, but not where the money goes.
Key idea
There is no payee field. The vault pays only the payee stored in the mandate the owner signed.
- The owner signs a mandate The vault stores its payee, its limits and the one agent key that may use it.
- The agent sends a spend It names a mandate, an amount, an invoice hash, a ref and a decision hash. No payee.
- The vault pays the stored payee It reads the destination from the mandate in its own storage.
What the agent sends#
The contract and the TypeScript package define the same five fields:
Rust (contract)
#[near(serializers = [json])]
#[derive(Clone, Debug, PartialEq)]
pub struct SpendRequest {
pub mandate_id: u64,
pub amount: U128,
pub invoice_hash: String,
pub r#ref: String,
pub decision_hash: String,
}TypeScript (core)
export interface SpendRequest {
mandate_id: number;
amount: U128;
invoice_hash: string;
ref: string;
decision_hash: string;
}The Rust tab is from contracts/vault/src/types.rs. The TypeScript tab is from packages/core/src/types.ts, exported by @probarion/core. ref is a Rust keyword, so the contract writes the field r#ref; on the wire the JSON key is ref.
| Field | What it holds |
|---|---|
mandate_id | The mandate to spend under. Ids start at 1. The payee comes with the mandate. |
amount | A decimal string in the token's smallest unit. USDC has 6 decimals, so 5 USDC is "5000000". It must be above 0. |
invoice_hash | 64 lowercase hex. While a spend with this hash is held, pending, paid or burned, the vault refuses another one. A failed, rejected or expired spend gives the hash back. |
ref | 1 to 64 bytes, an idempotency key the caller picks. Once the contract accepts a spend, held or sent for payout, its ref stays used, even if the payout fails. A refused spend undoes every change, so its ref stays free. |
decision_hash | 64 lowercase hex: the body hash of the signed decision entry in the ledger. The contract checks only its form. The verifier ties it to a decision. |
None of the five is an address. The agent key is a function-call key that the vault adds to itself for the method spend only, so this request is all the agent key can send. The free check_spend view takes the same request plus a signer key, and has no payee either.
Where the money goes instead#
Both spend and approve hand the payout to the same function, execute_payout. It reads the payee from the mandate in the vault's storage, never from the request:
let payee = self
.mandate(spend.mandate_id)
.map(|m| m.payee.clone())
.unwrap_or_else(|| fail(ErrorCode::NoMandate));The vault's record of a spend has no destination field either. When you read a spend with get_spend, its destination is filled in from the mandate.
Approver signs
An approver signs approve or reject for a held spend from their own wallet, with exactly 1 yoctoNEAR attached. approve names only the spend id, so the payout still goes to the mandate's payee.
A NEAR payee#
The vault calls ft_transfer on its USDC token, to the mandate's account_id, with the memo probarion:<spend id>. When the transfer succeeds, the spend becomes paid.
A Base or Solana payee#
The vault calls ft_withdraw on the NEAR Intents contract for the mandate's POA asset, the token inside NEAR Intents that stands for USDC on the payee's chain, with the memo WITHDRAW_TO:<address>. The vault builds that memo from the mandate alone:
impl Payee {
/// `WITHDRAW_TO:<address>` for an Intents payee, built only from mandate storage (I7).
pub fn withdraw_memo(&self) -> Option<String> {
match self {
Payee::Intents { address, .. } => Some(format!("{WITHDRAW_MEMO_PREFIX}{address}")),
Payee::Near { .. } => None,
}
}
}create_mandate accepts only an asset on the vault's allow list, which is set when the vault is created. Before it sends, the vault checks the mandate's asset against that list again, or refuses with AssetNotAllowed. When the withdrawal runs on NEAR, the spend becomes burned, and get_spend shows the same memo. On mainnet the POA bridge then releases the USDC on the payee's chain, Base or Solana. No such payout has run on mainnet yet: spike S1, a contract account withdrawing Base POA USDC to a Base address, is unproven.
The console offers NEAR and Base payees. The CLI and the contract also accept a Solana address.
On testnet#
intents-stub.probarion.testnet stands in for intents.near, and the tokens are test tokens: a burn is recorded, and nothing leaves NEAR. On 2026-11-01 a smoke run on v0.probarion.testnet sent one spend to a NEAR account and one to a Base address, with the agent key signing spend directly:
| Payee | Transaction | What the vault sent |
|---|---|---|
probarion-payee.testnet, a NEAR account | 3DFV2thB…Fy9cX (opens in a new tab) | ft_transfer of 1 test USDC with the memo probarion:1 |
0x1111…1111, a Base address | 3u4ffrwp…dcvYe (opens in a new tab) | ft_withdraw of 1 test USDC with the memo WITHDRAW_TO:0x1111111111111111111111111111111111111111, built from mandate 2 |
The run had no Solana payee.
The code layer refuses a contradicting payee#
The SDK, @probarion/sdk, has process(), which runs one whole payment cycle for an invoice. An optional model reads the invoice and proposes to pay, hold or refuse, and its answer comes with an attestation, a signed report that the SDK checks. How one payment works walks through the cycle.
Before anything is sent, decide() in @probarion/core runs the rule mandate.payee_mismatch. It compares a payee_hint with the payee of the chosen mandate. A hint can come from two places:
- the invoice facts the caller passes to
process(); - the model's proposal, but only when its attestation checks passed.
requestSpend() passes no invoice hint, so there only the model's hint is compared. A hint is what the invoice claims, and the type says what it is for:
export interface ModelProposal {
action: "pay" | "hold" | "refuse";
mandate_id: number | null;
amount: string | null; // smallest units
payee_hint: PayeeHint | null; // what the invoice claims; never used as destination
reasoning: string; // ≤ 2000 chars
confidence: number; // 0..1
}
export interface PayeeHint {
near_account?: string;
chain?: "base" | "solana";
address?: string;
name?: string;
}| The mandate pays | A hint refuses when it names |
|---|---|
| A NEAR account | Another NEAR account; an address other than the mandate's NEAR account, or that account when it is also a Base or Solana address (an ETH-implicit 0x account, for example); or a chain with no NEAR account and no address |
| A Base or Solana address | Any NEAR account, another chain, or another address |
Base addresses match in any letter case. Solana addresses must match exactly, which is inferred: it follows from the code (sameAddress in policy.ts), and no test shows it yet. A name alone never refuses. For a NEAR payee, the mandate's own NEAR account does not refuse either, in near_account or in address and whatever chain sits next to it: it names no other destination. The model's proposal schema has no NEAR chain and no account field but near_account, so a model may put that account in address, next to base or solana. The account must match exactly, letter case included. An account that is also a valid Base or Solana address, such as an ETH-implicit account (0x and 40 hex), still refuses in address, since it names that address too. A refusal ends the cycle: process() returns refused_by_code, the decision goes into the ledger, and nothing is sent.
The hint is compared, never passed to the contract. When the rule refuses, its detail in the decision entry quotes the account, chain or address the hint names. decide() returns a verdict, a mandate id, an amount, the rules and who decided, with no destination. The SDK builds SpendRequest from the decision's mandate id and amount, the invoice hash, the ref and the decision hash, so no hint reaches the contract.
The model gets the same rule. Its default system prompt includes this line:
"Payments always go to the mandate's payee. Put the payee the invoice claims (NEAR account, chain and address, name) in payee_hint. If it differs from the mandate payee, choose refuse.",The model is shown the invoice text and the candidate mandate, payee included, but not the hint the caller passed.
This rule runs off chain only. The contract has no PayeeMismatch code, because it receives no payee to compare. A caller who signs spend with the agent key directly skips the rule, as the testnet smoke run did. requestSpend() with skipPreflight: true also skips decide(), so no hint is compared. Either way, the vault still pays only the mandate's payee.
How this is kept true#
- The field names. A web test reads
SpendRequestfrompackages/core/src/types.tsand fails if any field name matches/recipient|payee|address|receiver|destination/i. A second test reads the contract'sSpendRequestfromcontracts/vault/src/types.rsand fails unless its fields are the same five, in the same order. Both are inapps/web/test/site-facts.test.ts. - The memo. A sandbox test in
contracts/integration-tests/tests/flows.rscreates a mandate for a Base address and spends 7 USDC of a test token under it. It checks that the burn carries exactlyWITHDRAW_TO:and the mandate's address, on the allowed asset, and thatget_spendshows the same memo. - The decision. A test in
packages/core/test/policy.test.tschecks thatdecide()refuses whenever the invoice or the model names another payee, and that a decision has no destination field. - The payee never changes.
create_mandateis the only vault method that takes a payee, and no method edits one.withdrawandwithdraw_intentssend only to the owner's own account. To pay someone new, the owner signs a new mandate, and can revoke the old one withrevoke_mandateso it refuses new spends. The CLI'smandate createprints the rule with the transaction: "The payee of a mandate never changes: a new payee needs a new mandate." This is inferred: it follows from the code, and no test checks that text yet. - After the fact. The verifier's check h looks at every burned spend. It passes when each one has exactly one matched burn pair, on a POA asset of the vault's network: a POA
ft_burnwhose memo isWITHDRAW_TO:and the mandate's address, and the vault's Intentsmt_burnfor the spend amount. A burn isunprovenwhen its transaction could not be read right now, or when no transaction was named for it because the spend is pre-ledger, was paid out by an approver, or has no payment entry. A transaction that was read and holds no such pair fails h, and so does any other burned spend whose burn is not found.
Owner signs
Choosing a payee is an owner action: it happens once, when the owner signs the mandate. The owner signs create_mandate from their own wallet and attaches a storage deposit, and the vault sends back what it does not need. Revoking a mandate with revoke_mandate takes exactly 1 yoctoNEAR.
Where to go next#
- Mandates: every field of a mandate, the payee included.
- Vaults, roles and keys: who holds the agent key, and what it can call.
- Payment checks: the 14 checks every spend meets.
- How one payment works: the whole cycle, from mandate to record.
- Let an agent pay: run the cycle from your agent.
- Security model: what each key can and cannot do.
- Glossary: the terms on this page.
What this does not do#
- It does not stop a stolen agent key from spending. Whoever holds an agent key can spend inside the open mandates that name that key, with invoice hashes of its choosing, up to their limits and the daily backstop. It cannot pay anyone a mandate does not name.
- It does not check that the owner chose the right payee. The vault pays the payee the mandate names. The contract refuses a NEAR payee that is the vault itself and checks the form of a Base or Solana address. The console also refuses the vault's USDC token, its Intents contract and its allowed POA assets as a NEAR payee.
- It does not show that an invoice is real. The ledger stores no invoice text.
- It does not compare a hint on chain. The hint check runs off chain, and a caller can skip it: see The code layer refuses a contradicting payee.
- It does not show delivery on Base or Solana on testnet. A cross-chain payout stops at the stand-in contract, and nothing leaves NEAR.
Built from 4ab41ca · reviewed
contracts/vault/src/types.rs(opens in a new tab)contracts/vault/src/spend.rs(opens in a new tab)contracts/vault/src/checks.rs(opens in a new tab)contracts/vault/src/mandates.rs(opens in a new tab)contracts/vault/src/held.rs(opens in a new tab)contracts/vault/src/admin.rs(opens in a new tab)contracts/vault/src/keys.rs(opens in a new tab)contracts/vault/src/lib.rs(opens in a new tab)packages/core/src/types.ts(opens in a new tab)packages/core/src/policy.ts(opens in a new tab)packages/core/src/proposal-request.ts(opens in a new tab)packages/core/test/policy.test.ts(opens in a new tab)packages/sdk/src/probarion.ts(opens in a new tab)packages/cli/src/commands/mandate.ts(opens in a new tab)contracts/integration-tests/tests/flows.rs(opens in a new tab)apps/web/test/site-facts.test.ts(opens in a new tab)apps/web/src/lib/console/mandates.ts(opens in a new tab)SECURITY.md(opens in a new tab)README.md(opens in a new tab)docs/RUNBOOK.md(opens in a new tab)contracts/deployments/testnet.json(opens in a new tab)apps/web/test/fixtures/testnet-spend-trees.json(opens in a new tab)docs/specs/2026-10-21-probarion-interfaces.md(opens in a new tab)contracts/vault/src/validate.rs(opens in a new tab)contracts/vault/src/errors.rs(opens in a new tab)packages/core/src/verify.ts(opens in a new tab)packages/core/test/verify.test.ts(opens in a new tab)packages/core/test/verify-task14.test.ts(opens in a new tab)packages/cli/src/owner-tx.ts(opens in a new tab)packages/cli/src/payee.ts(opens in a new tab)packages/cli/test/owner-commands.test.ts(opens in a new tab)apps/web/src/lib/site/facts.ts(opens in a new tab)docs/evidence/mainnet-v0/README.md(opens in a new tab)docs/runbooks/mainnet-v0.md(opens in a new tab)