# Probarion docs, full text > A mandate layer for AI agents on NEAR. The agent pays only inside mandates its owner signed, a NEAR contract checks every payment, and the owner can publish the signed ledger for anyone to verify. Rules for agents reading these docs: 1. Status words mean what they say. Live works in this deployment. Preview is built but not yet run end to end, or not open yet. Planned is not built. 2. NEAR testnet is live. On mainnet one small vault is live, `v0.probarion.near`, since 2026-11-11: no mainnet factory is deployed, and no Base payout has run on mainnet. 3. The packages are on npm. To run the evaluation, the fixtures or the scripts, build from a clone, as /docs/build/install shows. 4. An agent key can call only `spend`. Owner and approver actions are signed by a person, not by Probarion and not by an agent. 5. A spend request has no payee field. Do not look for one and do not add one. 6. Do not resend a spend whose outcome is `unconfirmed`. First look for its `ref` in the vault's spend list. 7. A contract refusal reads `PROBARION:`. Each code is explained at /docs/build/errors#\. 8. Each page names the day it was reviewed and the commit the docs were built from: its Markdown twin in its first lines, and every HTML page but the docs home in its source box. Where a page and the code disagree, the code wins. 9. When you build or review the repository, follow AGENTS.md. # Probarion docs > Guides, references and verify-it-yourself recipes for Probarion, a mandate layer that lets AI agents pay on NEAR only inside limits an owner signed. You sign the limits in a NEAR contract, the agent pays inside them, and every payment is on chain for anyone to check. Reviewed: 2026-11-09 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs **Popular searches** - [mandate fields](https://probarion.xyz/docs/concepts/mandates.md) - [PROBARION:NoMandate](https://probarion.xyz/docs/build/errors.md#NoMandate) - [verify --offline](https://probarion.xyz/docs/trust/verify-a-vault.md) - [unconfirmed](https://probarion.xyz/docs/build/spend-outcomes.md) **[What Probarion is, in one payment](https://probarion.xyz/docs/start/how-one-payment-works.md)** (New here · 5 min) Follow one payment from the owner's mandate to the contract's decision and a record its owner can publish for anyone to check. No wallet needed. [What is Probarion](https://probarion.xyz/docs/start/what-is-probarion.md) **[Set up a vault and your first mandate](https://probarion.xyz/docs/start/owner-quickstart.md)** (Owner · 10 min) Sign in with a NEAR wallet, create a testnet vault, fund it with test USDC, add an agent key and sign the limits. **[Let your agent request its first spend](https://probarion.xyz/docs/start/developer-quickstart.md)** (Developer · 15 min) Build the SDK from source, run one payment on testnet and branch on the seven statuses `process()` returns. **[Check it without a wallet](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md)** (Evaluator · Don't trust us) Ask the contract in your browser, then run the verifier on a signed test ledger and read the testnet vault on chain. [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md) ## What happens to one invoice The model may advise. Code decides. The contract has the last word. 1. **Owner** Signs a mandate: who gets paid, how much per payment and in total, how many times, until when, which agent key may use it, and above what amount a person must approve. 2. **Code** Decides to allow the payment, hold it or refuse it, with built-in rules that cannot be removed. A model may advise, but it cannot widen the mandate. The SDK signs the decision into the ledger before anything is sent, refusals included. If code refuses, nothing is sent. 3. **Agent** Asks to spend. Its `spend` request carries a mandate id, an amount, a ref and two hashes, one of them the signed decision's. It has no field for a payee. 4. **Contract** Runs 14 checks in a fixed order. It pays the mandate's payee, holds the spend for an approver, or refuses with a code such as `NoMandate`. The SDK signs that outcome into the ledger. 5. **Anyone** Checks a published record. Each ledger entry is chained by hash to the one before, and the SDK anchors the root on the vault with the ledger key. The verifier runs checks a to i with no account and ends with PASS, FAIL or UNPROVEN. Follow the whole path in [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md), or see each check in [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md). ## Popular guides The ideas behind every payment, one page each. New words are in the [glossary](https://probarion.xyz/docs/resources/glossary.md). **[Write a mandate](https://probarion.xyz/docs/concepts/mandates.md)** Every field, what the contract checks when the owner signs one, and why a mandate is never edited. **[Approve a held payment](https://probarion.xyz/docs/concepts/holds-and-approvals.md)** What puts a payment on hold, who may approve or reject it, and what happens when the hold expires. **[Give each agent its own key](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md)** Who may call what: the owner, the approvers, the spend-only agent key and the anchor-only ledger key. **[Why the agent cannot name a payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md)** A spend request has no address field. The vault pays the payee the owner set in the mandate. **[Read the signed ledger](https://probarion.xyz/docs/concepts/ledger-and-anchors.md)** How each decision is signed into a hash-chained ledger before money moves, and how anchors pin it on the vault. **[Read the security model](https://probarion.xyz/docs/trust/security-model.md)** Who holds which key, what the vault enforces whatever the off-chain code does, and the known gaps. ## Build on it The SDK, the core library, the CLI and the MCP server are on npm, at 0.1.1. A clone of the repository builds them from source too. **[Install the packages](https://probarion.xyz/docs/build/install.md)** Install the SDK, the core library and the CLI from npm, or build them from a clone. `@probarion/sdk` **[Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md)** One payment cycle with the SDK: decide, sign the decision, check, spend, record the outcome. `process()` **[Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md)** The seven statuses `process()` returns, what each one means, and what to do next. `SpendOutcome` **[Errors](https://probarion.xyz/docs/build/errors.md)** Each `PROBARION:` code: what it means, which call raises it and what to do next. The SDK and HTTP errors are there too. `PROBARION:OverPerPayment` ## Docs your agent can read **For AI agents and their builders** Every page is also plain Markdown. Point a coding agent at the index, or paste a page into a chat. - [/llms.txt](https://probarion.xyz/llms.txt): the index - [/llms-full.txt](https://probarion.xyz/llms-full.txt): every page in one file - [/docs/concepts/payment-checks.md](https://probarion.xyz/docs/concepts/payment-checks.md): one page [For AI agents](https://probarion.xyz/docs/resources/for-ai-agents.md) ## What works today, and what does not Live works in this deployment. Preview is built but not yet run end to end, or not open yet. Planned is not built. | Feature | Status | Detail | | - | - | - | | Vault contract | Live | On testnet | | Offline verifier, checks a to i | Live | Shipped, on npm | | Reproducible builds | Live | Shipped | | Vault factory | Live | On testnet | | Web console: vault, keys, faucet, NEAR USDC top-up, mandate, Payables, Agent (MCP) tab, Freeze, approvals, Reject, revoke, withdraw, key removal, proof page | Live | Tried on testnet | | Web console: email alerts, Base top-up | Preview | Built, not tried yet | | Public proof page | Live | Live for the testnet demo vault | | TypeScript SDK and CLI | Live | 0.1.1 on npm | | Payables agent | Live | Live on testnet | | Live attested model run | Preview | Checked online on testnet, image not pinned | | Canary public challenge | Live | Live on testnet | | MCP server | Live | 0.1.1 on npm, run by a real MCP client on testnet | | Mainnet vault | Live | On mainnet: 1 payment, 1 refusal, no Base payout yet | NEAR testnet is live, with one small vault on mainnet, and no third party has audited this code. [What works today](https://probarion.xyz/docs/start/what-works-today.md) adds the parts this list leaves out, and [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md) says what differs between the two networks. --- # What is Probarion > Probarion is a mandate layer for AI agents on NEAR: an owner signs spending limits, a vault contract checks every payment, a signed ledger records it. Probarion is a mandate layer for AI agents on NEAR. The agent pays people and services only inside mandates its owner signed, a NEAR contract checks every payment, and the SDK signs every decision into a ledger the owner can publish for anyone to verify. Status: Live · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/what-is-probarion An owner puts USDC in a vault, a NEAR contract of their own, and signs mandates from their own wallet: who gets paid, how much per payment and in total, how many times, until when, and above what amount a person must approve. The agent gets one key that can call `spend` and nothing else. Testnet today, and one small vault on mainnet. [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md) says what differs between the two. ## The problem it solves Giving an AI agent a wallet means trusting a prompt with money. Probarion moves the rule into a NEAR contract. The limit sits on the money's path, not in a prompt or in the agent's own process. Even if every line of off-chain code were wrong, the vault refuses a payment outside its mandate. A payment made through the SDK passes these layers, in this order: 1. **Model proposes** A private model on NEAR AI Cloud may advise. It is optional and cannot widen a mandate. On testnet, the Canary's ledger of 2026-11-10, published in the repository, holds two of its attested decisions, which pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/README.md#the-signed-ledger-published-2026-11-11), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/verify-online-2026-11-11.txt)). The testnet demo vault's ledger holds five more, made by Payables, which pass A1 to A7 offline; four of them, entries 10 to 13, also passed O1 and O2 online on 2026-11-12. The code allowed the last two of them, and the vault paid both invoices ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-online-2026-11-12.txt)). The TEE measurement is not pinned, so they do not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-a8-2026-11-12.txt)). 2. **Code decides** `decide()` runs built-in rules that cannot be removed. The SDK signs the decision into the ledger before anything moves. 3. **Contract enforces** The vault checks the spend against its mandate, then pays, holds it for an approver, or refuses. 4. **Ledger records** The SDK signs every decision, refusals included, and the result of each spend it sends, each chained to the entry before it. 5. **Anyone re-checks** Once the owner publishes the ledger, the verifier ties each spend to exactly one decision in it. The ledger is kept by the SDK, not required by the contract. The vault checks only the form of a spend's decision hash, not that a ledger holds the decision, so a spend sent with the agent key alone has no decision in the ledger, and [verifier check d](https://probarion.xyz/docs/trust/verifier-checks.md#check-d) fails on it. That is the case today on `v0.probarion.testnet`: its 2026-11-01 smoke run sent spends with no SDK and no ledger, so a live verify of that vault prints `RESULT: FAIL`. On the testnet demo vault, `demo.vaults.probarion.testnet`, the console's Payables agent signed each decision into the ledger before its spend, so a live verify of it prints `RESULT: PASS`. Some outcomes stay out of the ledger. A result entry that fails to write is skipped, and the outcome says `ledgerComplete: false`. An approver's later approve or reject, and the expiry of a hold, are recorded on chain, and no code writes them to the ledger. That last point is inferred: it follows from the code, and no test shows it yet. [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md) follows one payment through these steps. ## The idea in four numbers | Number | What it counts | | - | - | | 0 | Full-access keys on the vault. A public RPC call shows it. | | 1 | Method the agent's key can call: `spend`. | | 0 | Recipient arguments on `spend`. The payee comes from the mandate. | | 9 | Checks anyone can run. Live, they read the chain. Offline, they check a snapshot, a saved file of a ledger and its chain data, against itself, so [take the snapshot yourself](https://probarion.xyz/docs/trust/verify-a-vault.md#offline-is-not-live). | The 9 checks belong to the verifier, which reads the record afterwards. They are not the 14 checks the vault runs on every spend before it pays. [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md) lists the 14, and [verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md) lists the 9. > **The agent cannot name a payee.** > > A forged invoice or a hijacked prompt can ask to pay anyone. `spend` has no field to put an address in, so the money can only go to a payee you signed. For a cross-chain payee, the vault builds the withdrawal memo itself, from the mandate. [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md) explains the design. The request an agent sends, in full: `packages/core/src/types.ts` ```ts export interface SpendRequest { mandate_id: number; amount: U128; invoice_hash: string; ref: string; decision_hash: string; } ``` ## The parts | Part | What it is | Status | | - | - | - | | Mandates | The owner signs each one from their own wallet: one payee (a NEAR account, or a Base or Solana address paid out through NEAR Intents), `max_per_payment`, `max_total`, `max_uses`, `expires_at_ns`, `approval_above`, and the one agent key that may use it. | Live | | The vault contract | The owner's own contract. It holds the USDC, checks every spend against its mandate and a daily backstop (a cap on the total it pays or sets aside in one UTC day, fixed when the vault is created), holds a spend above `approval_above` for an approver, and pays the mandate's payee. | Live | | The factory | Creates vaults with no access keys. On testnet it is `vaults.probarion.testnet`. | Live | | The agent key | A function-call key the vault adds to itself on the owner's instruction, limited to `spend`. | Live | | The ledger key | A second function-call key, limited to `anchor`. The SDK signs every ledger entry with it and anchors the ledger root on the vault. | Preview | | `@probarion/core` | `decide()`, the pure policy engine, plus the ledger and the verifier's checks a to i. No network, no file system. | Live | | `@probarion/sdk` | The `Probarion` class, which runs one payment cycle with `process()`, plus the ledger stores, `snapshot()` and `verify()`. 0.1.1 is on npm. | Live | | The `probarion` CLI | Prints each owner and approver transaction for `near-cli-rs` and signs none. It also makes agent keys, runs `check_spend` (the contract's free dry run of a spend) and runs the verifier. 0.1.1 is on npm. | Live | | The web console | Wallet sign-in, vaults, mandates, keys, top-up, alerts, and Payables (the next row). It runs on testnet: open it with "Open console" in the site header. | Preview | | Payables | The console's reference agent that pays invoices. It pays by itself only when an attested model confirms the amount the code read; without one, nothing is sent and the invoice waits for the owner. On 2026-11-12 it paid two invoices of 2.00 test USDC each on its own ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). | Live | Live means it works in this deployment; where a Live part runs on a chain, it runs on NEAR testnet. On testnet a stub stands in for NEAR Intents, so a Base or Solana payout is only logged as a burn with its memo, and nothing leaves NEAR. Preview means it is built but not yet run end to end with real wallets, or not open yet. `@probarion/core` counts as Live because its verifier already runs on this site. The "Change one field. Watch it break." test on the landing page runs checks a to i in your browser, and so does "Try the verifier here" on [/proof](https://probarion.xyz/proof) while no public vault is listed there. In the status list it is the [offline verifier](https://probarion.xyz/docs/start/what-works-today.md#offline-verifier) row. `@probarion/core`, `@probarion/sdk` and the `probarion` CLI are on npm at 0.1.1, as is the MCP server `@probarion/mcp` (Live): see [Install the packages](https://probarion.xyz/docs/build/install.md). In the status list the SDK and the CLI are the [TypeScript SDK and CLI](https://probarion.xyz/docs/start/what-works-today.md#sdk-and-cli) row, Live since release 0.1.0. Their read-only calls run against the testnet vault, as [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md) shows. The console's Payables runs the payment loop through `process()` on testnet, but no run of that loop in an agent of your own is recorded, so the pages that walk it are Preview. [What works today](https://probarion.xyz/docs/start/what-works-today.md) has the full status list. ## Who does what - **The owner** signs every mandate, adds and removes agent keys and sets the ledger key, all from their own wallet. Without Probarion, the owner can also freeze the vault, unfreeze it, and withdraw its USDC and its NEAR Intents balance, the tokens it holds inside NEAR Intents to pay Base and Solana payees. Freeze takes no deposit. A mandate takes a storage deposit, and the vault sends back the excess. Most other owner methods need exactly 1 yoctoNEAR, which a function-call key cannot attach, so a dapp login key on the owner's account cannot call them. - **Approvers** decide a held spend: approve sends the payment, reject releases the amount the hold set aside under the mandate. They are named when the vault is created, and no method changes them later. - **The agent** asks to spend. Its key can call `spend` and nothing else, and only under the mandates that name that key. - **Anyone** can read a vault on chain and run the verifier, with no account. - **Probarion** runs the site and the console. If you use the console's Keys tab or Payables, Probarion generates the vault's agent and ledger keys on its server and stores each secret encrypted. [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) covers each role and key in full, and [mandates](https://probarion.xyz/docs/concepts/mandates.md) covers every mandate field. ## Where to go next - **You own the money.** The [owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md) sets up a testnet vault in the web console, with test USDC, an agent key, a ledger key and one signed mandate. - **You build the agent.** The [developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md) connects your agent to a testnet vault with the TypeScript SDK and runs one payment cycle with `process()`. - **You want to check the claims.** [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md) runs the verifier and reads the testnet vault with no wallet, key or account. The two quickstarts are Preview: their flows have not yet run end to end with real wallets. ## What this does not do - **It holds no owner key.** In the site's own words: "Non-custodial: Probarion holds no owner key." The agent and ledger keys the console makes are another matter: see [who does what](#who-does-what). - **It cannot change the rules underneath the owner.** The vault has no upgrade method, no method to change the owner, and no full-access key. - **It does not edit a mandate.** The payee is fixed when the mandate is created. A new payee needs a new mandate, and the owner can revoke the old one. - **A vault's NEAR does not come back out.** The vault pays for its storage and its spends' gas from its own NEAR, and it has no method to withdraw NEAR. That NEAR is the vault's runway, and one plain transfer tops it up. - **The contract does not judge whether a payment is wise.** It holds the agent to the limits the owner signed, and a spend above `approval_above` waits for a person. Off chain, `decide()` can still hold or refuse a payment the mandate allows, through its built-in rules, your own `custom.*` rules or an attested model's proposal. - **It has no HTTP API that an agent pays through.** The console's invoice API answers only a signed-in owner or approver, on a same-origin request. An agent pays through the SDK, in its own process. - **Some parts are not done yet.** On mainnet one small vault runs, but no Base payout has run there and the mainnet factory is not deployed. On testnet, Payables paid two invoices on its own with an attested model on 2026-11-12, with test tokens ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). It has not run on mainnet, and an unfinished payment stays marked "do not resend" until its reconcile job is built. > **Unaudited beta.** > > No third party has audited this code. It runs on NEAR testnet with test tokens: use small amounts. [SECURITY.md](https://github.com/probarion/probarion/blob/main/SECURITY.md#what-is-not-proven-yet) lists what the verifier does not prove yet, and the [security model](https://probarion.xyz/docs/trust/security-model.md) covers the rest. --- # How one payment works > Follow one payment from the owner's mandate through code, the vault's 14 checks and the signed ledger to a record the owner can publish for anyone to verify. An owner signs a mandate. When an invoice comes in, code decides and signs that decision into a ledger, the agent asks the vault to pay under the mandate, the vault contract checks the request again and pays, holds or refuses, and anyone can later tie the payment to its decision. Status: Live · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/how-one-payment-works 1. **The owner signs a mandate** One payee, the limits, and the one agent key that may use them. 2. **A private model may advise** Optional. It cannot widen the mandate. 3. **Code decides and signs** The decision goes into a signed ledger before anything is sent. 4. **The contract decides last** It runs 14 checks, then pays, holds or refuses. 5. **Anyone checks the record** The verifier ties each spend on chain to exactly one decision. ## Who signs what - **The owner**, from their own wallet: the vault (`create_vault` on the factory, which makes its caller the owner), each mandate and its revocation, every change to the vault's keys, `freeze`, `unfreeze` and both withdrawals (`withdraw` and `withdraw_intents`). - **The agent**, with the agent key: `spend`. - **The SDK**, with the ledger key: each ledger entry, and `anchor`. - **An approver**, from their own wallet: `approve` or `reject` for a held spend. - **Anyone**, from any account: `expire_held`, once a hold's time has run out. The agent key may call only `spend`, and the ledger key only `anchor`. ## The owner signs a mandate A mandate names one payee: a NEAR account, or a Base or Solana address paid through NEAR Intents. It sets `max_per_payment`, `max_total`, `max_uses`, `expires_at_ns` and `approval_above`, the one agent key that may spend under it, and `purpose_hash`, the hash of its purchase order. The payee never changes. The landing page's example pays only `probarion-payee.testnet`: 5 USDC a payment, 20 USDC in total, 5 uses, approval above 5 USDC. Its approval threshold equals its per-payment cap, so the contract never holds a spend under it: a payment above 5 USDC is refused with [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment) before the hold test. Every field is on [Mandates](https://probarion.xyz/docs/concepts/mandates.md). > **Owner signs** > > `create_mandate` takes a storage deposit, and the vault refunds the excess. The agent key cannot create or revoke a mandate. > > _The owner signs this transaction. Probarion never signs it._ ## A private model may advise Optional: the model runs on NEAR AI Cloud inside a TEE, a trusted execution environment: isolated hardware that returns a signed report, an [attestation](https://probarion.xyz/docs/resources/glossary.md#attestation), with each answer. The SDK puts a [nonce](https://probarion.xyz/docs/resources/glossary.md#nonce) in each request, so that report fits only the next entry of this vault's ledger, for this invoice and this mandate. Without a model, or when its attestation checks fail, code decides alone and the decision entry says `rules-only`. > **Preview** > > Adapter built. On testnet, the Canary's ledger of 2026-11-10, published in the repository, holds two attested decisions that pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/README.md#the-signed-ledger-published-2026-11-11), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/verify-online-2026-11-11.txt)). On the testnet demo vault, Payables made five more, and on 2026-11-12 the code allowed the fourth and the fifth, both by `Qwen/Qwen3.8-27B`, and the vault paid 2.00 test USDC as spend 4 and again as spend 5 ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-online-2026-11-12.txt)). The TEE measurement is not pinned, so this does not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-a8-2026-11-12.txt)). ## Code decides, and signs before anything moves A payment starts when the agent gets an invoice. The agent passes `process()` the invoice text, its facts and a `ref`. The facts include the amount, the `invoice_hash` of that text and the `purpose_hash` of the purchase order the invoice cites. In the SDK, `process()` runs the whole cycle. `decide()` in `@probarion/core` makes the decision. It is pure, so the same input always gives the same decision, and its built-in rules cannot be removed. It picks the mandate by `purpose_hash`, so the destination is always that mandate's payee. The SDK signs the decision into the ledger before anything is sent, refusals included, and its body hash goes to the contract as `decision_hash`. If code refused, `process()` returns `refused_by_code` and no spend is sent. Code can also hold a payment. `decide()` holds when the payee is paid through NEAR Intents and the vault's balance there is unknown or below the amount, when a model proposal whose attestation checks passed asks to hold or has a confidence below the floor, or when a custom rule added to the SDK holds. See [Code hold](https://probarion.xyz/docs/build/spend-outcomes.md#code-hold). ## The agent asks the vault to spend The agent's request: `packages/core/src/types.ts` ```ts export interface SpendRequest { mandate_id: number; amount: U128; invoice_hash: string; ref: string; decision_hash: string; } ``` The caller picks `ref`, 1 to 64 bytes, and the vault accepts each ref once. > **There is no payee field** > > The vault reads the destination from the mandate, so a hijacked prompt has no field to put an address in. See [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). The SDK first asks the free `check_spend` view whether the contract would accept the request. If not, `process()` returns `refused_by_contract` and no spend is sent. If code held the payment but the contract would pay at once, the SDK holds it off-chain: `process()` returns `held` with `spendId` null. This is a [code hold](https://probarion.xyz/docs/resources/glossary.md#code-hold): no spend was sent and no call releases it. The fix is to change what caused the hold, then run the cycle again. Otherwise the SDK sends `spend` with the agent key. ## The contract decides last The vault runs 14 checks in a fixed order, from the agent key, the frozen flag and the mandate to the amount, the invoice, the ref and the daily backstop. The first that fails is the answer: see [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md). Limits are inclusive, so a payment equal to a cap passes. The contract holds a spend only when its amount is above `approval_above`. A refusal panics with `PROBARION:` and undoes every change, so its ref stays free. Once the contract accepts a spend, held or paid, its ref is used for good, even if the payout fails. A held spend pays nothing until one approver from the vault's list approves it, before the hold expires (72 hours on `v0.probarion.testnet`). After that, anyone can call `expire_held` to release it. See [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md). > **Approver signs** > > `approve` and `reject` each take exactly 1 yoctoNEAR. > > _An approver signs this transaction. Probarion never signs it._ ## The vault pays the mandate's payee - A NEAR payee is paid with `ft_transfer`. The spend becomes `paid`. - A Base or Solana payee is paid through NEAR Intents: the vault calls `ft_withdraw` with the memo `WITHDRAW_TO:`, built from the mandate. The spend becomes `burned` once the withdrawal runs on NEAR. If the payout fails, the spend becomes `failed` and its reservation is released. On testnet, `intents-stub.probarion.testnet` stands in for `intents.near` and nothing leaves NEAR. ## The ledger records it, and anyone can check a published record After a `check_spend` refusal or a `spend` attempt, the SDK writes a second entry with the outcome. Each entry is signed with the ledger key (Ed25519) and [chained to the one before it](https://probarion.xyz/docs/concepts/ledger-and-anchors.md). After every cycle, code refusals and code holds included, the SDK anchors the ledger on the vault with `anchor(seq, root, count)`: `seq` is one above the last anchor's, `root` is the `link` of the last entry, and `count` is the number of entries. A failed anchor is only logged, and the next cycle anchors the whole tail. [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) lists the seven statuses `process()` returns. The verifier runs checks a to i on the ledger and the vault. Check d ties each spend on chain to exactly one decision. See [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md) and [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md). ## See it on testnet On 2026-11-01 a smoke run sent test-token spends to `v0.probarion.testnet` with the agent key alone: no SDK, no model, no ledger. | What happened | Transaction | Result | | - | - | - | | Paid a NEAR account | [`3DFV2thB…Fy9cX`](https://testnet.nearblocks.io/txns/3DFV2thBU9V4b5w7kM691poLJ8SWiCqp46LKBn1Fy9cX) | Spend 1 `paid`: 1 test USDC to `probarion-payee.testnet` | | Burned for a Base address | [`3u4ffrwp…dcvYe`](https://testnet.nearblocks.io/txns/3u4ffrwpiwLoNV6cU72NHLVBAMq729NwLs6ixDLdcvYe) | Spend 2 `burned`: 1 test USDC, memo `WITHDRAW_TO:0x1111…1111` | | Refused mandate 999, which does not exist | [`AkjyVPmb…dzgY`](https://testnet.nearblocks.io/txns/AkjyVPmb6LdMES7ykpzJQR7EXXGvw3kYH6Nhem9YdzgY) | `PROBARION:NoMandate`, nothing paid | With no ledger, the vault has no ledger key or anchors yet. So `verify --vault v0.probarion.testnet` prints `RESULT: FAIL` today: check d finds spends with no decision in the ledger, and check h cannot find the burn of spend 2, because no ledger entry records the hash of the transaction that made it. On the testnet demo vault, `demo.vaults.probarion.testnet`, the console's Payables agent signed each decision into the ledger before its spend, and a live `verify` of it prints `RESULT: PASS` with 9 checks passed. To try the free checks with no wallet, see [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md). ## Where to go next - **You own the money.** The [owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md) sets up a testnet vault in the web console, with test USDC, an agent key, a ledger key and one signed mandate. - **You build the agent.** The [developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md) connects your agent to a testnet vault with the TypeScript SDK and runs one payment cycle with `process()`. - **You want to check the claims.** [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md) runs the verifier and reads the testnet vault with no wallet, key or account. The two quickstarts are Preview: their flows have not yet run end to end with real wallets. ## What this does not do - The model cannot widen a mandate, and the agent cannot pick a payee. - Code cannot pay past the contract: the vault refuses any payment outside its mandate. - The smoke run proves the contract path, not the SDK's ledger on a live vault, which is Preview. - `verify --offline` checks a saved [snapshot](https://probarion.xyz/docs/resources/glossary.md#snapshot), one file with a ledger and the vault's chain data, against itself: a snapshot you did not take proves nothing about the chain. `verify --vault` reads the chain itself. --- # Evaluate in 5 minutes > Check Probarion with no wallet or key: build from source, run the verifier on a test ledger, and read the testnet vault on chain. Check Probarion's main claims yourself, with no wallet, no key and no account. Status: Live · Network: Testnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/evaluate-in-5-minutes In about five minutes, with Node.js 24, pnpm 10, git and a bash-compatible shell (Linux, macOS or Git Bash on Windows), you check Probarion's main claims yourself: the verifier catches a one-field edit, the testnet vault has no full-access key, the contract refuses what a mandate does not allow, and the CLI signs nothing. These are the terminal steps of the [README's evaluation](https://github.com/probarion/probarion/blob/main/README.md#how-to-evaluate-probarion-in-5-minutes) except its claims check and its MCP demo, with the build line the site uses, for after the browser check below. Step 5 also uses `curl`. > **Before you install anything** > > Two checks run in your browser, on the landing page. In the [live check](https://probarion.xyz/#try), pick "A hijacked prompt": the vault's `check_spend` view answers `OverPerPayment`. The other scenarios answer `ok`, `NoMandate` and `NotAgent`. The [ledger test](https://probarion.xyz/#verify) runs the verifier from `@probarion/core` in your browser on the signed test fixture of step 2. > **No wallet, no key, no account** > > Nothing on this page signs or sends a transaction. After the build, steps 2, 3 and 7 run offline, and steps 4 to 6 read public NEAR testnet data with no key. ## Run the checks 1. **Build from source.** The checks use files of the repository: the signed test fixture, `scripts/testnet/chain.mjs` and `contracts/code-hashes.json`. So you build the packages from a clone of the public repository, not from npm. The last line builds the CLI and the two workspace packages it needs, `@probarion/core` and `@probarion/sdk`. Steps 2 and 6 import from their `dist` folders. ```bash git clone https://github.com/probarion/probarion cd probarion pnpm install pnpm --filter "{packages/cli}..." build ``` Run every later command from the `probarion` folder. If you cloned it before, run `git pull`, `pnpm install` and the build line again. The README's own step 1 runs `pnpm install --frozen-lockfile` and `pnpm build`, which also builds the web app; you do not need it here. 2. **Watch the verifier catch a one-field edit.** `runChecks` from `@probarion/core` runs checks a to i over a snapshot of a ledger and its chain state. The snapshot here is a committed test fixture, [`valid-snapshot.json`](https://github.com/probarion/probarion/blob/main/packages/core/test/fixtures/valid-snapshot.json), signed with test keys. It is not a live vault. The script checks it as recorded, changes the amount of one decision, and checks it again. ```bash node --input-type=module -e ' import { readFileSync } from "node:fs"; import { firstBrokenSeq, runChecks } from "./packages/core/dist/index.js"; const snapshot = JSON.parse(readFileSync("packages/core/test/fixtures/valid-snapshot.json", "utf8")); const report = await runChecks(snapshot); console.log("as recorded:", report.ok, report.checks.map((c) => `${c.id} ${c.status}`).join(", ")); snapshot.ledger[2].body.amount = "1"; // one decision: 40 USDC becomes 0.000001 USDC const edited = await runChecks(snapshot); console.log("after one edit:", edited.ok, "first broken entry:", firstBrokenSeq(edited)); for (const c of edited.checks) if (c.status !== "pass") console.log(` ${c.id} ${c.status}: ${c.detail}`); ' ``` Expected output: ```text as recorded: true a pass, b pass, c pass, d pass, e pass, f pass, g pass, h pass, i pass after one edit: false first broken entry: 2 a fail: entry 2 body_hash does not recompute from its body: a signature over a hash the body does not produce proves nothing about the body b fail: entry 2 body_hash does not recompute from its body d fail: spend 2 does not match its decision seq 2 (spend vs decision): amount 40000000 vs 1 ``` One changed field no longer matches the entry's signed `body_hash`, so checks a and b fail on entry 2, and its amount no longer matches the spend in the snapshot's chain state, so check d fails. The verifier names entry 2 as the first broken one. 3. **Run the same checks with the CLI.** The CLI you just built runs the same checks over the same fixture, offline. The README's step runs it as `npx probarion verify --offline …` instead, which downloads release 0.1.1 from npm once and prints the same report. ```bash node packages/cli/dist/bin.js verify --offline packages/core/test/fixtures/valid-snapshot.json ``` It exits 0 and prints the report below. The `generated` time on the second line is the moment you run it, so it reads `` here; every other line is as the CLI prints it. The header names the fixture's vault, `x.vaults.probarion.testnet (testnet)`, which is test data and not a real vault. The second line counts its 5 entries and 3 anchors, and the third says that the chain data comes from the file, as recorded when the snapshot was taken. Then come one PASS row for each check a to i and the totals. ```text Probarion verify: x.vaults.probarion.testnet (testnet) entries 5, anchors 3, generated snapshot taken 2026-11-05T12:00:00.000Z: its chain data is the file's and was not read from the chain again a ledger-signatures PASS 5 entries: each body_hash recomputes from its body and carries a valid signature by the ledger key the chain names for its range (1 ledger key(s) named by the anchors and the vault config); check b also verifies every link and the chain b hash-links PASS 5 entries: every body_hash and link recomputes, chained from the zero link c anchors PASS 3 anchors match the ledger; 0 entries after the last anchored count (5) are not chain-attested d spends-have-decisions PASS 2 on-chain spends, each bound to its own allow or hold decision (same invoice, mandate and amount) e attestations PASS 2 attested decisions pass A1–A7; quote not verified offline (run --online for O1; re-take the snapshot live with --online); A8 not run (the request is not checked against the invoice); rules-only or unattested (reported, not failed): seq 4 f vault-locked PASS no FullAccess key; every access key is the ledger key or a spend-only agent key on the vault; code_hash 9xTTGSkSHPbw2DCumDxoraeR7uL8Pc2ANm5XKjhohnWL is version 1 of the canonical factory vaults.probarion.testnet; it is the code of factory version 1 g intents-keys PASS no intents public keys; predecessor auth enabled h burn-memos PASS 1 burned spend has a matched burn pair: the POA ft_burn memo is WITHDRAW_TO: and the vault's intents mt_burn is for the spend amount i factory-version PASS created by vaults.probarion.testnet at version 1 9 passed, 0 failed, 0 unproven RESULT: PASS ``` `verify` exits 0 when every check passes, 1 when a check fails or verify cannot run, 2 when no check fails but one is unproven, and 64 for a usage error. On the snapshot as step 2 edits it, the CLI's tests show exit 1 and the line `RESULT: FAIL (first broken seq 2)`. 4. **Read the testnet vault on chain.** These commands read public RPC data, with no key. [`chain.mjs`](https://github.com/probarion/probarion/blob/main/scripts/testnet/chain.mjs) only reads: it signs and sends nothing. ```bash node scripts/testnet/chain.mjs code-hash v0.probarion.testnet # 6tbERLAXWjaVKbSNzaivRDh7H9kCcjtmV1qGrgz7entR grep -n 6tbERLAXWjaVKbSNzaivRDh7H9kCcjtmV1qGrgz7entR contracts/code-hashes.json # the recorded reproducible build node scripts/testnet/chain.mjs full-access-keys v0.probarion.testnet # prints an empty line: no full-access key node scripts/testnet/chain.mjs key-count v0.probarion.testnet # 1: one access key ``` Expected output: ```text 6tbERLAXWjaVKbSNzaivRDh7H9kCcjtmV1qGrgz7entR 10: "code_hash": "6tbERLAXWjaVKbSNzaivRDh7H9kCcjtmV1qGrgz7entR", 1 ``` The vault's code hash is the reproducible build recorded in [`contracts/code-hashes.json`](https://github.com/probarion/probarion/blob/main/contracts/code-hashes.json). The empty third line means the vault has no full-access key. `key-count` counts access keys of any kind, so the last line shows only that the vault has one. Its kind comes from check f of `verify --vault v0.probarion.testnet`, the run in the callout at the end of this page. Check f reads each key's permission and passes only when every key is the ledger key or a spend-only agent key on the vault. It passes on this vault, which has no ledger key, so the one key is a spend-only agent key. That run fails on checks d and h, for the reasons the callout gives. 5. **Read the smoke run's transactions.** On 2026-11-01 the agent key called `spend` directly: once on mandate 1, a NEAR payee; once on mandate 2, a Base payee; and once on mandate 999, which does not exist. A payment to a Base address is a burn: the vault calls `ft_withdraw` on the intents contract with the [`WITHDRAW_TO` memo](https://probarion.xyz/docs/resources/glossary.md#withdraw_to-memo), `WITHDRAW_TO:
`, and the spend becomes [burned](https://probarion.xyz/docs/resources/glossary.md#burned). On mainnet the POA bridge then releases the USDC to that address on Base. No such payout has run on mainnet yet: spike S1 is unproven. On 2026-11-09 the agent key also called `spend` on mandate 1 for 6 test USDC, above its limit of 5 per payment, and the contract refused it. Read each transaction from an archival node: ```bash tx() { curl -s https://archival-rpc.testnet.fastnear.com -H 'Content-Type: application/json' -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tx\",\"params\":{\"tx_hash\":\"$1\",\"sender_account_id\":\"v0.probarion.testnet\",\"wait_until\":\"NONE\"}}"; } tx 3DFV2thBU9V4b5w7kM691poLJ8SWiCqp46LKBn1Fy9cX | grep -o 'spend_paid' | sort -u # spend_paid tx 3u4ffrwpiwLoNV6cU72NHLVBAMq729NwLs6ixDLdcvYe | grep -o 'WITHDRAW_TO:0x[0-9a-f]*' | sort -u # the burn memo, built from the mandate tx AkjyVPmb6LdMES7ykpzJQR7EXXGvw3kYH6Nhem9YdzgY | grep -o 'PROBARION:[A-Za-z]*' | sort -u # PROBARION:NoMandate tx 4715d4nbYn6cq981nmLU5y8efCoX2w3SbgW8PPq4Vsr1 | grep -o 'PROBARION:[A-Za-z]*' | sort -u # PROBARION:OverPerPayment ``` Expected output, one line for each transaction: ```text spend_paid WITHDRAW_TO:0x1111111111111111111111111111111111111111 PROBARION:NoMandate PROBARION:OverPerPayment ``` The first spend paid a NEAR account. For the second, the vault built the `WITHDRAW_TO:` memo itself, from mandate 2: the spend request carries no address. The third was refused by the contract with [`NoMandate`](https://probarion.xyz/docs/build/errors.md#NoMandate), and the fourth with [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment): nothing was paid. On testnet, [`intents-stub.probarion.testnet`](https://probarion.xyz/docs/resources/glossary.md#intents-stub) stands in for `intents.near`, so the burn is recorded and nothing leaves NEAR. These reads need the archival node to answer; NearBlocks' testnet index can lag behind the chain. 6. **Ask the vault itself.** A read-only `Probarion` instance (no keys, `model: null`) lists the mandates and runs the contract's free `check_spend` dry run. Mandate 1 allows at most 5 test USDC per payment, so the contract accepts 2 and refuses 6. Amounts are in USDC's smallest unit: `2000000` is 2 test USDC. ```bash node --input-type=module -e ' import { Probarion } from "./packages/sdk/dist/index.js"; const vault = new Probarion({ network: "testnet", vault: "v0.probarion.testnet", model: null }); for (const m of await vault.mandates.list()) console.log(`mandate ${m.id} "${m.label}": ${m.status}, pays ${m.payee.account_id ?? m.payee.address}, at most ${m.max_per_payment} per payment`); const invoiceHash = "ab".repeat(32); console.log("2 USDC:", await vault.checkSpend({ mandateId: 1, amount: "2000000", invoiceHash, ref: "README-1" })); console.log("6 USDC:", await vault.checkSpend({ mandateId: 1, amount: "6000000", invoiceHash, ref: "README-1" })); ' ``` Expected output: ```text mandate 1 "smoke near": active, pays probarion-payee.testnet, at most 5000000 per payment mandate 2 "smoke base": active, pays 0x1111111111111111111111111111111111111111, at most 5000000 per payment 2 USDC: { ok: true, code: null, would_hold: false } 6 USDC: { ok: false, code: 'OverPerPayment', would_hold: false } ``` Mandate 2's payee is the address in step 5's burn memo. The 6 USDC answer is [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment). `would_hold` is false in both answers: 2 test USDC is not above the mandate's 5 test USDC approval threshold, and a refused dry run always answers `would_hold: false`. The smoke mandates set that threshold equal to the per-payment limit, so no spend on them is ever held. The [`ref`](https://probarion.xyz/docs/resources/glossary.md#ref) is the caller's own key for one payment: once the contract accepts a spend, held or paid, its ref is used up, and a later spend with the same ref is refused with `DuplicateRef`. A dry run changes nothing, so both calls can pass `README-1`. > **The smoke mandates expire on 2026-12-01** > > The smoke mandates expire 30 days after the 2026-11-01 run, and the answers above hold until then. After that, both dry runs should answer `MandateExpired`, because the contract checks expiry before the per-payment limit. `mandates.list()` will still print `active`: a mandate's status does not count its expiry. This follows from the contract's code; no test shows it on this vault yet. 7. **Print an owner transaction without signing it.** The CLI checks the input against the contract's rules, writes the arguments to a file and prints the `near-cli-rs` command for the owner to review and sign in their own wallet. It signs nothing. ```bash node packages/cli/dist/bin.js mandate create --vault v0.probarion.testnet \ --payee near:probarion-payee.testnet --max-per 5 --max-total 20 --uses 5 --expires 2026-12-31 \ --approval-above 2 --agent-pk ed25519:5errZXjDMXpk51BLypA3hBRUfBfh9v4bHeyU9vRmzYFK \ --purpose docs/evidence/v0-po.txt --label "Contractor" --out-dir probarion-tx node packages/cli/dist/bin.js mandate create --vault v0.probarion.testnet --payee base:0x1234 \ --max-per 5 --max-total 20 --uses 5 --expires 2026-12-31 --approval-above 2 \ --agent-pk ed25519:5errZXjDMXpk51BLypA3hBRUfBfh9v4bHeyU9vRmzYFK --purpose docs/evidence/v0-po.txt --label x # error: --payee base:0x1234 is not a 0x-prefixed 20-byte address ``` The first command exits 0. It writes `create_mandate.c6242930417b.args.json` into `probarion-tx`, where `c6242930417b` is the start of the file's sha256, and prints the transaction for the owner: ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: near contract call-function as-transaction v0.probarion.testnet create_mandate file-args ./probarion-tx/create_mandate.c6242930417b.args.json prepaid-gas '100.0 Tgas' attached-deposit '0.05 NEAR' sign-as network-config testnet sign-with-keychain send sha256 of the args file: c6242930417b2efaa85ee46ff5a2346b7ba8e0fa9a94125ae7fe3b57ad132016 (check: sha256sum ./probarion-tx/create_mandate.c6242930417b.args.json, or Get-FileHash -Algorithm SHA256 ./probarion-tx/create_mandate.c6242930417b.args.json in PowerShell) Run it in bash, zsh or PowerShell (not cmd.exe). create_mandate arguments (./probarion-tx/create_mandate.c6242930417b.args.json): { "input": { "payee": { "type": "near", "account_id": "probarion-payee.testnet" }, "purpose_hash": "30cdebeaa06b60c55f48662123b77b7fe97c0d44ce3c9d10c861c690e21488dc", "max_per_payment": "5000000", "max_total": "20000000", "max_uses": 5, "expires_at_ns": "1798761599999000000", "approval_above": "2000000", "agent_pk": "ed25519:5errZXjDMXpk51BLypA3hBRUfBfh9v4bHeyU9vRmzYFK", "label": "Contractor" } } The payee of a mandate never changes: a new payee needs a new mandate. The vault registers the payee's storage on the USDC contract (0.00125 NEAR) out of the deposit; the excess is refunded to you. With a Ledger hardware wallet, replace `sign-with-keychain send` with `sign-with-ledger usb send`. ``` The amounts are in USDC's smallest unit, `purpose_hash` is the sha256 of `docs/evidence/v0-po.txt`, and `expires_at_ns` is the end of 2026-12-31 UTC, in nanoseconds. The command attaches the default 0.05 NEAR deposit, of which the vault refunds the excess, and leaves `` for the owner to fill in. `--expires` must be in the future: after 2026-12-31, this command answers `error: --expires 2026-12-31 is not in the future`, so use a later date, which changes the file name, its sha256 and `expires_at_ns`. The second command stops at the payee, because a Base address is `0x` and 40 hex digits. It writes no file, exits 1 and prints: ```text error: --payee base:0x1234 is not a 0x-prefixed 20-byte address ``` > **Owner signs** > > The printed `create_mandate` transaction is one the vault's owner would sign in their own wallet. This tutorial does not run the printed command, so nothing is signed or sent. To create a mandate on your own vault, see [Owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md). > > _The owner signs this transaction. Probarion never signs it._ ## What you just checked | Step | What it shows | | - | - | | 1 | The CLI, the SDK and the core library build from the public source. | | 2 and 3 | The verifier catches a one-field edit to a signed ledger and names the first broken entry. The CLI runs the same checks offline. | | 4 | `v0.probarion.testnet` runs the build recorded in `contracts/code-hashes.json` and has no full-access key. | | 5 | The contract refused a spend on a mandate that does not exist, and the vault built a cross-chain memo from the mandate, not from the request. | | 6 | A mandate fixes one payee and its limits, and the contract's free dry run refuses an amount over the per-payment limit. | | 7 | The CLI checks an owner transaction and prints it for the owner. It signs nothing. | To see why the agent's request has no payee field, read [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). ## What this does not show - The fixture of steps 2 and 3 is test data signed with test keys. Its vault, `x.vaults.probarion.testnet`, is not a real vault. - `verify --offline` checks a snapshot against itself, so a snapshot you did not take proves nothing about the chain. `verify --vault` reads the chain itself. - The vault source changed after the `v0.probarion.testnet` deployment, with a `set_ledger_key` fix on 2026-11-03, so this vault runs the earlier build. The vaults the testnet factory creates run the later build, code hash `3cQXYDvfGLRMveCVAHmh1exiuZKDXWqD8cUhqdEs9MF1`, also recorded in `contracts/code-hashes.json`. - Everything here is NEAR testnet, with test tokens. The one mainnet vault, `v0.probarion.near`, has its own record: see [the mainnet vault](https://probarion.xyz/docs/start/testnet-and-mainnet.md#the-mainnet-vault). - The contract source has not been audited. [SECURITY.md](https://github.com/probarion/probarion/blob/main/SECURITY.md#what-is-not-proven-yet) lists what `verify` does not prove yet. > **A live verify of the testnet vault fails today** > > ```bash > node packages/cli/dist/bin.js verify --vault v0.probarion.testnet > ``` > > It exits 1 and prints `RESULT: FAIL`. On stderr it first warns that the default ledger file, `./.probarion/ledger.jsonl`, does not exist, so it verifies the chain against an empty ledger. That is expected here. The smoke run sent its spends with the agent key directly, without the SDK, a model or a ledger, so this vault has no ledger key and no anchors. Check d finds spends with no decision in the ledger, and check h finds no burn event without the burn's tx hash. Check f, which step 4 relies on, passes, and its row reads: > > ```text > f vault-locked PASS no FullAccess key; every access key is the ledger key or a spend-only agent key on the vault; code_hash 6tbERLAXWjaVKbSNzaivRDh7H9kCcjtmV1qGrgz7entR is a recorded reproducible build (KNOWN_VAULT_CODE_HASHES) > ``` ## Next steps - [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md): run `verify --vault` and read its report. - [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md): what checks a to i prove, and what they do not. - [Security model](https://probarion.xyz/docs/trust/security-model.md): what a leaked agent key can and cannot do. - [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md): the path from an invoice to a paid, recorded spend. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks the vault runs on every spend, in order. - [Install](https://probarion.xyz/docs/build/install.md): build the packages to use them in your own code. - [What works today](https://probarion.xyz/docs/start/what-works-today.md): what is Live, Preview and Planned. To run every test as well, `pnpm verify` runs the Rust unit and property tests, Biome, the build, the typecheck, every TypeScript test and the launch checks. It needs Rust too: rustup installs the pinned toolchain from `rust-toolchain.toml`. On Windows, set `AWS_LC_SYS_PREBUILT_NASM=1` first or install NASM. --- # Owner quickstart > Sign in with a NEAR wallet, create a testnet vault, add test USDC, add the agent and ledger keys, and sign your first mandate in the console. In about 10 minutes, with a NEAR testnet account in a supported wallet and at least 3.4 test NEAR, you get a NEAR testnet vault with 25 test USDC, an agent key, a ledger key and one signed mandate, all set up in the web console. Status: Preview · Network: Testnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/owner-quickstart > **Note** > > Probarion is an unaudited beta, and this quickstart runs on NEAR testnet, where every token is a test token with no value. The page is marked Preview: sign-in and creating a vault have been tried with a real wallet (Meteor, on testnet), and the later steps have not yet been run end to end with one. ## Before you start - A NEAR testnet account, such as `name.testnet`, with a full-access key in a wallet the console lists for testnet: Meteor Wallet, Nightly Wallet or Ledger. Only Meteor has been tried with a real wallet so far. - At least 3.4 test NEAR in that account. Step 2's deposit is 3.23352 NEAR, which the sign-in page rounds up to about 3.3. Step 5 attaches 0.05 NEAR, and the vault sends most of it back. Each transaction you sign also needs some NEAR for its gas. The 3.4 is inferred from the code, with a little room: no test runs these steps from an account that holds only that much. - Probarion does not hand out test NEAR. NEAR's faucet service funds new testnet accounts: the repository's testnet runbook makes its accounts with near-cli-rs, `near account create-account sponsor-by-faucet-service`, and each starts with 10 test NEAR. - A testnet account for the mandate to pay, such as a second account of yours. - A small file to stand for the purchase order, such as a text file. Only its sha256 goes on chain. You sign every owner transaction on this page (creating the vault, adding the two keys, signing the mandate) in your own wallet, and Probarion never signs one of those for you. Two kinds of transaction are signed by keys Probarion holds. The test USDC mint in step 3 is signed by the Probarion team's key, which can only mint the test token. The agent and ledger keys from step 4 are kept on Probarion's server for Payables, and they sign `spend` and `anchor` on your vault: the vault lets them call nothing else. ## Set up your vault 1. **Sign in.** Open [/console](https://probarion.xyz/console) on this site. Today every address of the site serves NEAR testnet. Without a session you land on "Sign in with your NEAR wallet". Click "Sign in with NEAR wallet" and pick your wallet. The wallet signs a message for the recipient `probarion.xyz`, not a transaction: ```text Sign in to Probarion This proves you control this NEAR account. It does not move funds or add keys. Nonce: <64 hex characters> ``` Expected: the "Your vaults" page opens, its top line says "Signed in as" with your account, and the list says "No vaults yet. Create one, or register one you own." The session lasts 12 hours. 2. **Create a vault.** On "Your vaults", fill in the "Create a vault" card: - "Vault name": 2 to 31 characters, lowercase letters, digits, `-` or `_`, starting with a letter or a digit. The hint shows the vault's account, `.vaults.probarion.testnet`. - "Approvers": at least 1 and at most 10 NEAR accounts, comma separated, who approve held payments. A second person is best. If you name only yourself, the form warns: "You are the only approver, so a held payment needs nobody else's signature. A second person is safer." - "Daily backstop (USDC)": 500. Its hint says "Hard cap on the total spent per day, enforced by the contract." - "Held payments expire after (hours)": 72, from 1 to 720. - "Deposit (NEAR)": the form pre-fills what the factory requires, its `min_deposit` plus the console's estimate of the storage for the vault's own copy of its code. With today's testnet factory that is 3.23352 NEAR. The form refuses more than 5 NEAR. Click "Create vault in my wallet", read the confirmation, and sign in your wallet. > **Owner signs** > > Your wallet signs `create_vault` on the factory, `vaults.probarion.testnet`, as the account you signed in with, and attaches the deposit. That account becomes the vault's owner for good. > > _The owner signs this transaction. Probarion never signs it._ > **Warning** > > The deposit becomes the vault's runway and cannot be withdrawn. If the name is taken, the factory refunds it. Expected: the console registers the vault for you and says: ```text Registered .vaults.probarion.testnet. Opening it… ``` Then the console opens the vault's own page, and a line under its account ends in "you are owner". Its "Your first agent payment" card lists five steps to a first payment, with "Agent key and ledger key" next and two ways to do it: "Go to Keys" for hosted Payables, which this page uses, and "Connect agent" for your own AI agent (MCP). This page adds the test USDC first, and either order works. If the note says the vault could not be registered yet, wait a few seconds and enter its account in "Register a vault you already own" on the same page. 3. **Add test USDC.** Open your vault's "Top-up" tab (`/console//topup`), which only the vault's owner can use. In the card "USDC on NEAR (for NEAR payees)", under "Add 25 test USDC to this vault", click "Add test USDC". The server mints 25 test USDC straight into the vault. No wallet opens: the Probarion team's mint-only key signs this mint. It works once a UTC day per vault, and for up to 3 vaults per signed-in account a day. The banner at the top of the tab, which says you sign each top-up in your own wallet and Probarion never signs for you, is about the top-ups you send from your wallet. The faucet block itself says "The Probarion team's key signs this mint, not your wallet, and it can only mint the test token." Expected, with a link to the transaction: ```text Added 25 test USDC to the vault. ``` The "Balances" card on the "Overview" tab now shows the 25 test USDC under "USDC on NEAR". If the card shows a sentence instead of the button, this server has no faucet key yet. Do what it says: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." Test USDC is a test token that only its owner account can mint, so it comes from the Probarion team. Steps 4 to 6 do not need the USDC, so go on while you wait. Until it arrives, step 5 shows this warning, which does not stop you from creating the mandate: "The per-payment cap (5 USDC) is more than the USDC on NEAR the vault holds now (0 USDC). You can still create the mandate, but a payment above 0 USDC will fail until you add funds." If you already hold test USDC, the same card sends it from your wallet: set "Amount (test USDC)" and click "Send USDC to the vault from my wallet". You sign that transaction, and it also registers the vault on the USDC contract. The second card, "Base USDC inside NEAR Intents (for Base payees)", offers nothing on testnet: NEAR Intents and 1Click exist on mainnet only. 4. **Add the agent key and the ledger key.** Open the "Keys" tab. In the "Add keys" card, keep the "Agent key label" `payables` and click "Generate agent key and add it in my wallet". Then click "Generate ledger key and set it in my wallet". Probarion generates each key on its server and stores its secret encrypted, and your wallet adds only the public key to the vault. The vault limits the agent key to `spend`, with a gas allowance of 1 NEAR, and the ledger key to `anchor`, with 0.25 NEAR. The ledger key signs ledger entries and anchors their root on chain. > **Owner signs** > > Each button is one transaction on your vault: `add_agent` for the agent key, `set_ledger_key` for the ledger key. Each attaches exactly 1 yoctoNEAR. > > _The owner signs this transaction. Probarion never signs it._ Expected: after each signature the page reads the chain again, and the "Keys" table shows an `agent` row labelled `payables` and a `ledger` row, both with the status `active`. If a key still says `pending`, click "Check on-chain status". These two keys are for Payables, the agent Probarion runs for you, and the console never shows their secrets. With them, this vault belongs to hosted Payables. A vault has one ledger key, and the SDK's `process()` refuses to run when its `ledgerKey` is not the vault's, so an agent you run yourself with the SDK needs a second vault of its own: create it with steps 2 and 3, skip this Keys step, and add the agent's own keys as the [developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md) shows. By the code, two ledger writers on one vault would give it two histories, and the verifier's anchor check would then fail. This is inferred: no test shows it yet. 5. **Sign your first mandate.** Open the "Mandates" tab. In the "New mandate" card, skip the "Trying it out?" box above "Payee type": its "Use the sample PO" button fills the form for a different test, one that pays you and holds no payment. Fill in the card by hand: - "Payee type": "NEAR account (USDC on NEAR)". On testnet a Base payee is paid to a stand-in for NEAR Intents, and nothing leaves NEAR. The faucet adds only USDC on NEAR, and the Top-up tab cannot top up a Base balance on testnet, so a Base payee on this vault has nothing to pay from. - "Payee": the testnet account to pay. It never changes, so check every character. - "Purpose document (PO or contract)": choose your small file. Your browser hashes it and does not upload it: only its sha256 goes on chain. The form takes a file of at most 4 MiB, or the sha256 pasted into "Purpose hash (sha256, 64 hex)". Keep the file: Payables pays an invoice only when the purchase order sent with it hashes to this value. - "Max per payment (USDC)", "Max total (USDC)", "Max number of payments" and "Hold for approval above (USDC)" start at 100, 1000, 10 and 100. For this test, set them to 5, 20, 5 and 2, so the 25 test USDC cover the caps and a payment above 2 USDC waits for an approver. - "Expires on (UTC)": a date at least one hour and at most 366 days ahead. The mandate runs to the end of that UTC day. - "Agent key": `payables`, the key from step 4. - "Name": for example `First test`. Check the summary under "You are about to create". Its approval line reads "Payments above 2 USDC are held for an approver's signature; smaller ones are paid at once." Click "Sign mandate in my wallet". The console asks you to confirm: ```text Create mandate "First test"? Pays (USDC on NEAR) up to 5 USDC per payment and 20 USDC in all, at most 5 payments, until . The payee can never be changed. Your wallet attaches 0.05 NEAR; the vault refunds what the storage does not use. ``` > **Owner signs** > > Your wallet signs `create_mandate` on your vault and attaches 0.05 NEAR. The vault keeps what the mandate's storage costs, plus 0.00125 NEAR to register a NEAR payee on the USDC contract, and sends the rest back to you. > > _The owner signs this transaction. Probarion never signs it._ Expected: the "Mandates" table lists the mandate with the status `active`. 6. **Know your emergency stop.** On the "Overview" tab, the "Vault" card has a "Freeze vault" button. Its confirmation says "Freeze stops every spend and every approval until you unfreeze. Continue?" Do not freeze now. Know where the button is. > **Owner signs** > > `freeze` takes no deposit. "Unfreeze vault" signs `unfreeze`, which attaches exactly 1 yoctoNEAR, as most other owner calls do. > > _The owner signs this transaction. Probarion never signs it._ ## What you have now | What | Where to see it | What it is | | - | - | - | | Your vault, `.vaults.probarion.testnet` | "Overview" tab, "Vault" card | You are its owner. It has your approvers, a daily backstop of 500 USDC unless you changed it, and held payments that expire after 72 hours. | | Its NEAR runway | "Overview" tab, "Balances" card, "NEAR runway" | What is left of the deposit after storage. It pays the vault's storage and gas. | | Test USDC: 25 from the button, or what the team sent you | "Overview" tab, "Balances" card, "USDC on NEAR" | What a mandate to a NEAR payee pays from. | | The agent key `payables` | "Keys" tab, status `active` | May call only `spend` on the vault, with a 1 NEAR gas allowance. | | The ledger key | "Keys" tab, and "Ledger key" in the "Vault" card | May call only `anchor`, with a 0.25 NEAR gas allowance. | | One mandate | "Mandates" tab, status `active` | One payee, 5 USDC per payment, 20 USDC in all, 5 payments, your expiry date, held above 2 USDC, bound to `payables`. | Terms used on this page are in the [glossary](https://probarion.xyz/docs/resources/glossary.md). ## Common mistakes - **No agent key yet.** Until the vault has an agent key on chain, the "New mandate" card shows "A mandate belongs to one agent key, and this vault has none yet. Add one on the Keys tab first." instead of the form. Do step 4 first. - **A deposit above 5 NEAR.** The form refuses it: "At most 5 NEAR: if the name is taken, the factory refunds the deposit from a float sized for that." - **The wrong key at sign-in.** Sign-in takes only an ed25519 full-access key of the account. A function-call key gets "Your wallet's signature was not accepted. Choose an account whose full-access key can sign messages (bad_signature)". A key of another type gets "The wallet's answer was not in the expected form (invalid_input)". See [bad_signature](https://probarion.xyz/docs/build/errors.md#http-401-bad_signature) and [invalid_input](https://probarion.xyz/docs/build/errors.md#http-401-invalid_input). - **The other network.** This quickstart is testnet only. A session and a vault belong to the network of the address they were made at, and today every address serves testnet. See [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). - **A second faucet try the same day.** Each vault gets one mint a UTC day, so a second try answers "This vault already got its test USDC today." with the time left until midnight UTC. A fourth vault of the same account that day, or a day when the test USDC for all vaults is given out, gets a similar answer. If the button answers "The test USDC button is not working right now. Send @probarionhq a direct message on X with your vault's name.", do that. - **Trying to edit a mandate.** The console only creates and revokes mandates. > **Warning** > > A mandate cannot be edited, and its payee can never change. To change a limit or the payee, revoke the mandate on the "Mandates" tab and sign a new one. ## Next steps - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): what each limit you just signed means. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): what happens to a payment above your approval threshold. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md): who may do what on your vault. - [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md): the checks the vault runs on every spend. - [Developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md): connect an agent you run yourself with the SDK, on a second vault. - [Security model](https://probarion.xyz/docs/trust/security-model.md): what the design protects against, and what it does not. Guides to paying invoices with Payables, approving held payments in the console, and freezing and withdrawing come in a later phase. --- # Developer quickstart > Build the SDK from source, have the owner add your keys and sign a mandate, run one testnet payment with process() and handle each status. Your agent connects to a NEAR testnet vault through the TypeScript SDK, runs one payment cycle with process() and branches on every status it can return. Status: Preview · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/developer-quickstart In about 15 minutes your agent runs one payment on NEAR testnet with the Probarion SDK and branches on the answer; you need Node.js 24 or newer, pnpm 10 and git, and a vault owner (who can be you) with a NEAR testnet account holding a little more than 3.3 test NEAR, near-cli-rs with that account in its keychain or on a Ledger, and a second testnet account for the mandate to pay. In step 3 the owner creates the vault, adds the test USDC and signs your two keys and the mandate. Everything here runs on NEAR testnet with test tokens. On mainnet only one small vault runs, `v0.probarion.near`, and no other can be created yet: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). The console's Payables runs `process()` on testnet, with rules of its own added. In the demo vault's [evidence](https://github.com/probarion/probarion/tree/main/docs/evidence/demo-vault), `process()` held or refused each invoice on 2026-11-09 and 2026-11-11, and the owner's Confirm sent each spend of 2026-11-09 with `requestSpend()`. On 2026-11-12 two `process()` cycles of Payables each paid an invoice of 2.00 test USDC on its own, with an attested model: demo vault spends 4 and 5 ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). No rules-only run of `process()` that pays, like the script on this page, is recorded, and this script has not been run against a testnet vault, so the expected output below comes from the code and the SDK's own tests. > **From a clone** > > Step 1 clones the repository and builds the CLI together with the SDK and core packages it uses, so the scripts below run from that folder. The same packages are also on npm at 0.1.1: [Install the packages](https://probarion.xyz/docs/build/install.md) shows both ways. They are ESM only and need Node 24 or newer: load them with `import`, as there is no CommonJS build. > **Key idea** > > The SDK holds only two keys. The agent key can call `spend` and nothing else, the ledger key can call `anchor` and nothing else, and neither can attach a deposit. Every owner and approver action is signed by a person, in their own wallet or with near-cli-rs. ## Run one payment 1. **Build from source.** Run these in the folder where you keep projects, one line at a time: ```bash git clone https://github.com/probarion/probarion cd probarion pnpm install pnpm --filter "{packages/cli}..." build ``` Then check the CLI: ```bash node packages/cli/dist/bin.js --version ``` ```text 0.1.1 ``` The build also makes `packages/core/dist` and `packages/sdk/dist`, because the CLI depends on both. The scripts below live in this `probarion` folder, run from it, and import `./packages/sdk/dist/index.js` and `./packages/core/dist/index.js`. 2. **Make the agent key and the ledger key.** ```bash node packages/cli/dist/bin.js agent keygen --out ../agent-key.json node packages/cli/dist/bin.js agent keygen --out ../ledger-key.json ``` Each run saves the pair and prints only the public key (your path differs): ```text public_key: ed25519:... secret_key: saved to /home/you/agent-key.json (mode 0600), not printed Keep that file server-side only (read PROBARION_AGENT_KEY or PROBARION_LEDGER_KEY from it). Never commit it. Share only the public key with the vault owner, who adds it with "probarion agent add" or "probarion ledger set-key". ``` Each file holds `{ public_key, secret_key }` and keygen never overwrites one. On Windows the line has no `(mode 0600)`: the folder's permissions apply, and the CLI prints a warning that says so. The `../` keeps both files outside the clone. For a file inside a git work tree, the CLI warns you to check that git ignores it. 3. **Have the owner set up the vault.** Save the purchase order or contract the agent pays against as `../po.txt`. Its sha256 becomes the mandate's purpose hash. - The owner creates a testnet vault and adds test USDC, as in steps 1 to 3 of [Owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md): sign in, create the vault, add test USDC. The owner skips its steps 4 and 5: the console's Keys tab makes keys for Payables, the agent Probarion runs for the owner from the console, and your agent brings its own keys and gets its own mandate below. Once the vault is registered, the console opens its "Overview" tab, where the "Your first agent payment" card's next step is "Agent key and ledger key", with a "Go to Keys" link for hosted Payables and a "Connect agent" link for the Agent (MCP) tab. Skip "Go to Keys": your agent brings its own keys. The step ticks once your agent key and ledger key are on the vault. The card goes away after the vault's first payment. On the vault's Top-up tab (`/console//topup`), the "Add test USDC" button mints 25 test USDC into the vault with no wallet. The Probarion team's mint-only key signs it, once a UTC day per vault and for up to 3 vaults per signed-in account a day. If the site has no faucet key, the tab shows this instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." - An owner who makes the vault with `vault create` instead passes `--deposit 3.23352`, the figure the console pre-fills. The CLI checks the deposit only against the factory's 0.5 NEAR `min_deposit`, but today's testnet factory also charges the storage for the vault's own copy of its code, and it refuses a deposit that does not cover both with `InsufficientDeposit`. Such a vault joins the console through the "Register a vault you already own" card. - The owner tells you the vault's account, the owner account and the payee: a testnet account the owner controls, such as a second account of theirs. The payee of a mandate never changes, so check every character. You then print the three transactions that add your keys and the mandate: run these in your clone, in the `probarion` folder. The console cannot add your keys, because its Keys tab adds only keys that Probarion makes on its server. Long lines end in `\` as bash writes them; in PowerShell, put each command on one line. ```bash # Illustrative values: use your vault, the owner's account, the payee the owner named and the public keys keygen printed. # The agent key: public_key from ../agent-key.json node packages/cli/dist/bin.js --network testnet agent add --vault acme.vaults.probarion.testnet \ --pk ed25519:... --sign-as owner.testnet --out-dir probarion-tx # The ledger key: public_key from ../ledger-key.json node packages/cli/dist/bin.js --network testnet ledger set-key --vault acme.vaults.probarion.testnet \ --pk ed25519:... --sign-as owner.testnet --out-dir probarion-tx # The mandate: the agent key again, the payee, the limits and the purpose file. --expires is any future date. node packages/cli/dist/bin.js --network testnet mandate create --vault acme.vaults.probarion.testnet \ --payee near:vendor.testnet --max-per 5 --max-total 20 --uses 5 --expires 2026-12-31 \ --approval-above 2 --agent-pk ed25519:... --purpose ../po.txt --label "Agent quickstart" \ --sign-as owner.testnet --out-dir probarion-tx ``` Each command writes its arguments to a file in `probarion-tx/` and prints the near-cli-rs command with the file's sha256, under this line: ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: ``` Send the owner the three printed commands and the `probarion-tx` folder. Each command names its arguments file as `./probarion-tx/`, so the owner runs it in a folder that holds `probarion-tx`, in bash, zsh or PowerShell. The command ends in `sign-with-keychain send`, so the owner needs near-cli-rs with the owner account in its keychain (`near account import-account` puts it there). With a Ledger, the owner replaces `sign-with-keychain send` with `sign-with-ledger usb send`, as the CLI's own hint says. > **Owner signs** > > The owner reviews each printed command and its arguments file, then signs it. `agent add` and `ledger set-key` attach exactly 1 yoctoNEAR, and give the keys a gas allowance of 1 NEAR and 0.25 NEAR unless `--allowance` says otherwise. `mandate create` attaches 0.05 NEAR unless `--deposit` says otherwise: the vault keeps what the mandate's storage costs, plus 0.00125 NEAR to register a NEAR payee on the USDC contract, and refunds the rest. > > _The owner signs this transaction. Probarion never signs it._ This mandate pays only its payee, here `vendor.testnet`, at most 5 test USDC per payment and 20 in total over 5 uses, until the end of its expiry day in UTC. It holds any spend above 2 USDC for an approver. The console's mandate form lists every agent key on the vault, so once your agent key is added the owner can sign the mandate there instead, in their wallet, and you skip the `mandate create` command. Send the owner the same `po.txt` for it: the form hashes the file in the browser. > **One ledger key per vault** > > Do not add the console's ledger key to this vault. A vault has one ledger key, `ledger set-key` replaces the one before it, and `process()` refuses to run when its `ledgerKey` is not the vault's. If Payables runs on a vault, give your SDK agent a vault of its own. 4. **Dry-run a spend.** A read-only instance (no keys, `model: null`) calls the vault's free `check_spend` view, which runs the same checks as `spend` and changes nothing. Adapted from the README's evaluation, with the illustrative vault name: `dry-run.mjs` ```js import { readFile } from "node:fs/promises"; import { sha256Hex } from "./packages/core/dist/index.js"; import { Probarion, hashInvoice } from "./packages/sdk/dist/index.js"; // No keys: this instance can read the vault and dry-run a spend. It cannot pay. const vault = new Probarion({ network: "testnet", vault: "acme.vaults.probarion.testnet", model: null }); const purposeHash = sha256Hex(new Uint8Array(await readFile("../po.txt"))); for (const m of await vault.mandates.list()) { console.log(m.id, m.status, m.purpose_hash === purposeHash ? "purpose matches" : "other purpose"); } const text = "Invoice INV-1: 1.00 USDC for PO-1001"; const invoiceHash = hashInvoice(text); console.log(await vault.checkSpend({ mandateId: 1, amount: "1000000", invoiceHash, ref: "INV-1" })); ``` ```bash node dry-run.mjs ``` On a vault whose first mandate is this one: ```text 1 active purpose matches { ok: true, code: null, would_hold: false } ``` `code: 'NotAgent'` means the `agent add` transaction has not landed yet. The checks do not read the vault's USDC balance, so an empty vault passes the dry run too. Its payout then fails in the next step, and `process()` returns `failed`. 5. **Run one payment.** Illustrative, adapted from the SDK README: the imports point at the built files, the keys come from the two key files, and `model: null` lets the code decide alone. `pay-once.mjs` ```js import { readFile } from "node:fs/promises"; import { sha256Hex } from "./packages/core/dist/index.js"; import { Probarion, hashInvoice } from "./packages/sdk/dist/index.js"; const secretOf = async (file) => JSON.parse(await readFile(file, "utf8")).secret_key; const probarion = new Probarion({ network: "testnet", vault: "acme.vaults.probarion.testnet", agentKey: await secretOf("../agent-key.json"), // can call spend and nothing else ledgerKey: await secretOf("../ledger-key.json"), // can call anchor, and signs every ledger entry model: null, // the code decides alone }); const text = "Invoice INV-1: 1.00 USDC for PO-1001"; const outcome = await probarion.process({ text, // the invoice text; facts.invoice_hash is its hash ref: "INV-1", // your reference: the vault accepts each ref once facts: { invoice_hash: hashInvoice(text), amount: "1000000", // USDC has 6 decimals, so this is 1 USDC po_ref: "PO-1001", // copied verbatim from the text purpose_hash: sha256Hex(new Uint8Array(await readFile("../po.txt"))), // the file the mandate was signed with payee_hint: null, // what the invoice claims about the payee; never the destination }, }); console.log(outcome.status, outcome.spendId, outcome.code, outcome.ledgerSeq); ``` ```bash node pay-once.mjs ``` On a new vault with an empty ledger, a clean payment prints the status, the spend id, no code and the ledger position of the payment entry. These are the values the SDK's own test expects against its fake chain: ```text paid 1 null 1 ``` The decision entry sits at position 0, before anything is sent. The SDK picks the mandate by `purpose_hash`: the lowest-id mandate with that purpose that this agent key can still use. It writes the ledger to `.probarion/ledger.jsonl` in this folder unless `PROBARION_LEDGER_PATH` names another file. `outcome.txHash` is the spend transaction, and `outcome.anchorTxHash` is the anchor written after the cycle, or null when the anchor failed or was not needed. `model: null` keeps this quickstart on the code alone; the optional NEAR AI Cloud model step needs an API key, and its real attested run is Preview ([Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md)). 6. **Branch on the outcome.** `outcome.status` is one of the seven statuses `process()` returns, each described in the table below. Add this illustrative `switch` at the end of `pay-once.mjs`, after the `console.log` line: `pay-once.mjs` ```js switch (outcome.status) { case "paid": case "burned": // Paid. Record outcome.spendId and outcome.txHash. break; case "held": // spendId set: the contract holds it for an approver. // spendId null: a code rule held it (outcome.code) and nothing was sent. break; case "refused_by_code": case "refused_by_contract": // Not paid. outcome.code names the rule or the vault check that refused it. break; case "failed": // Definitely not paid. outcome.code says why. break; case "unconfirmed": // May have paid. Never send it again: find the spend by its ref first. break; // "settled" is in SpendOutcomeStatus, but process() never returns it. } ``` Run `pay-once.mjs` a second time and it gives `refused_by_code` with `invoice.duplicate`: the vault has paid that invoice and used that ref. Give each new payment its own invoice text and its own ref. 7. **Verify your vault.** Run it from the same folder, so the CLI reads the same default ledger: ```bash node packages/cli/dist/bin.js --network testnet verify --vault acme.vaults.probarion.testnet ``` The report starts with `Probarion verify: (testnet)`, lists each check, and ends with a count line and `RESULT: PASS`, `RESULT: FAIL` or `RESULT: UNPROVEN`. The exit code says the same: | Exit code | Meaning | | - | - | | 0 | Every check passes. | | 1 | A check fails, or verify could not run. | | 2 | No check fails, but at least one is unproven. | | 64 | A usage error: a missing, malformed or conflicting flag. | [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md) walks through a run, and [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md) explains each check. ## The seven statuses `process()` returns `SpendOutcomeStatus` also has `settled`, which `process()` never returns. A `held` outcome comes in two kinds, so the table gives each its own row. | Status | What happened | What to do | | - | - | - | | `paid` | The vault paid the mandate's NEAR payee. | Record `spendId` and `txHash`. | | `burned` | The vault paid out through NEAR Intents to a Base or Solana payee. The bridge releases the funds on the other chain, and Probarion does not watch that release yet, which is why `process()` never returns `settled`. On testnet the Intents contract is a test stand-in, so nothing reaches Base or Solana. | Record `spendId` and `txHash`. `burned` is final for the vault: do not poll it for a change or send it again. | | `held`, `spendId` set | The amount is above the mandate's approval threshold, so the contract holds the spend. | One of the vault's approvers approves it before the hold expires, or rejects it. Once the hold has expired, anyone can call `expire_held` to release it. See [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md). | | `held`, `spendId` null | A code rule held it. `code` names the rule, for example `intents.balance_low`. | Nothing was sent and no call releases it. Change what caused the hold, then run the cycle again. | | `refused_by_code` | A code rule refused it. `code` is the first refusing rule id. Nothing was sent. | Fix what `code` names, then run the cycle again. | | `refused_by_contract` | The vault refused it. `code` is the bare vault code. `txHash` is null after a `check_spend` refusal, when nothing was sent, and set after a `spend` that panicked. | Look the code up in [Errors](https://probarion.xyz/docs/build/errors.md). | | `failed` | Definitely not paid. `code` is the payout status, such as `failed`, when the vault took the spend and released it after its payout failed. Otherwise it says why the transaction did not run, such as `NotEnoughAllowance` or `LackBalanceForState`. | Fix the cause. If the vault took the spend, its ref stays used, so the retry needs a new ref. | | `unconfirmed` | `spend` was sent and may have paid. `code` says what is unknown. | Never send it again. Find the spend by its ref, as below. | > **Never resend an unconfirmed spend** > > An `unconfirmed` spend may have paid. Never send it again, least of all under a new ref. Find the spend by its ref, or wait: until the chain shows the spend or 48 hours pass, the SDK refuses the same invoice by code with `ledger.unresolved`. ### When a ref is used up A ref is used up once the contract accepts a spend, held or paid, and it stays used even if the payout fails. A refused spend reverts every change, so its ref stays free, and a code refusal sends nothing at all. The invoice is different: when the vault releases a spend (failed, rejected or expired), the same invoice can be paid again under a new ref. ### Find a spend by its ref `probarion.spends.get(id)` takes a spend id, and the contract has no lookup by ref. To find a spend by its ref, read the vault's spend list. Illustrative, adapted from the SDK README: `find-spend.mjs` ```js import { RpcClient, VaultClient, resolveRpcUrls } from "./packages/sdk/dist/index.js"; // Read-only: no sender, so it cannot sign anything. listAllSpends pages the contract's list_spends. const vault = new VaultClient("acme.vaults.probarion.testnet", new RpcClient({ urls: resolveRpcUrls("testnet") })); const spend = (await vault.listAllSpends()).find((s) => s.ref === "INV-1") ?? null; console.log(spend === null ? "no spend with this ref" : `spend ${spend.id}: ${spend.status}`); ``` A spend's status on the vault, as `list_spends` shows it: | Status | Meaning | | - | - | | `held` | The vault reserved the amount and holds the spend for an approver. Nothing is paid out yet. | | `pending` | The vault took the spend and sent the payout, and its callback has not settled it yet. | | `paid` | The mandate's NEAR payee was paid. | | `burned` | The withdrawal through NEAR Intents for a Base or Solana payee ran on NEAR. | | `failed` | The payout failed and the vault released the spend. Nothing was paid. | | `rejected` | An approver rejected the held spend and the vault released it. | | `expired` | The hold ran out and `expire_held` released the spend. | ## Common mistakes - **`this instance is read-only: process() and requestSpend() need agentKey (PROBARION_AGENT_KEY), the vault's function-call key for spend`.** The instance got no `agentKey`. The SDK does not read `PROBARION_AGENT_KEY` or `PROBARION_LEDGER_KEY` by itself: pass both keys as options. - **`process() and requestSpend() need ledgerKey (PROBARION_LEDGER_KEY): every ledger entry is signed with it`.** The instance got no `ledgerKey`. - **`the vault has no ledger key yet (set_ledger_key): entries signed now could never be verified`.** The owner's `ledger set-key` transaction has not landed yet. - **`ledgerKey is ..., but the vault's ledger key is ...`.** The vault holds another ledger key, such as the console's. See the warning in step 3. - **`mandate 1 does not exist and no agent key is configured`.** The dry run ran before the mandate landed, or the mandate has another id. - **`refused_by_code` with `invoice.amount_invalid`.** `facts.amount` was written in decimals, such as `"1.00"`. It is a decimal string in the token's smallest units: `"1000000"`. - **`refused_by_code` with `mandate.none`.** No mandate on the vault has this `purpose_hash`. Hash the very file the mandate was signed with, byte for byte. - **`InvalidInput` thrown before anything is written.** `facts.invoice_hash` is not `hashInvoice(text)`, or `facts.po_ref` is not copied verbatim from the text, with no trimming, case change or normalisation. - **The wrong RPC.** When `NEAR_RPC_URLS` is set, the SDK and the CLI use it for whichever network you pass. The `rpcUrls` option, or the CLI's `--rpc`, takes precedence over it. - **A ledger error on a USB stick or a network share.** The ledger's lock is made with a hard link, so its folder must be on a local disk whose file system supports hard links (NTFS, ext4, APFS). FAT, exFAT and some network shares do not. - **Two writers on one vault.** Run one writer per vault. Two ledger files give the vault two histories, and the anchor check of `verify` would then fail. This is inferred: it follows from the SDK's code, and no test shows it yet. ## Next steps - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle inside your own agent, with the optional model step. - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): every status and code in detail. - [Errors](https://probarion.xyz/docs/build/errors.md): the vault, SDK and HTTP error codes. - [The ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md): what each entry records and how anchors tie the ledger to the chain. - [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md): run the verifier and read its report. - [Install the packages](https://probarion.xyz/docs/build/install.md): installing from npm, and the build from source in full. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) and [Mandates](https://probarion.xyz/docs/concepts/mandates.md): who may do what, and what a mandate allows. - The [SDK README](https://github.com/probarion/probarion/blob/main/packages/sdk/README.md) lists the rest of the `Probarion` class. --- # Testnet and mainnet > Probarion runs on NEAR testnet with test tokens today, and one small vault on mainnet. What differs between the two networks and how the site picks one. Probarion runs on NEAR testnet today, with test tokens. On mainnet one small vault runs since 2026-11-11, with real USDC, and no mainnet factory is deployed. Status: Live · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/testnet-and-mainnet Probarion runs on NEAR testnet today. The vault contract and the factory are Live there, with test tokens in place of USDC. The web console is deployed there too, as Preview: its vault, keys, faucet, USDC top-up on NEAR, mandates, Agent (MCP) tab, Freeze and approvals have been tried with a real wallet, and on 2026-11-12 so were Reject, revoking a mandate, removing an agent key and Withdraw ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md#2026-11-12-reject-revoke-a-spare-agent-key-and-withdraw-all)). Email alerts have not been tried. Payables, the console's reference agent, is Live on testnet: it was tried with a real wallet too, and on 2026-11-12 it paid two invoices on its own with an attested model, with test tokens ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). On mainnet one small vault runs since 2026-11-11, `v0.probarion.near`, with real USDC: it made one NEAR payment and one refusal, and no Base payout has run on mainnet. The mainnet factory is not deployed, so no other mainnet vault can be created yet. One deployment of the site is built to serve both networks, and the address you open decides which one you get. That is Preview too: today only testnet is served, so every live address serves testnet. ## The two networks side by side | What | Testnet (Live) | Mainnet (one small vault) | | - | - | - | | USDC | `usdc.probarion.testnet`, a test token | `17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1`, native USDC | | USDC decimals | 6 | 6 | | NEAR Intents | `intents-stub.probarion.testnet`, a stand-in | `intents.near` | | Base USDC | `base-usdc.probarion.testnet`, a test token | `base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near`, POA USDC on Base | | Solana USDC | `sol-usdc.probarion.testnet`, a test token | `sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near`, POA USDC on Solana, marked as a stretch goal in the code | | Vault factory | `vaults.probarion.testnet`, deployed 2026-11-05 | `vaults.probarion.near`, not deployed | | First vault | `v0.probarion.testnet`, deployed 2026-11-01 | `v0.probarion.near`, deployed 2026-11-11, with no factory | | A new vault | `.vaults.probarion.testnet`. The console asks for about 3.3 test NEAR, which stays in the vault for its storage and gas | Cannot be created yet: the factory is not deployed | | Wallet | A testnet account, such as `name.testnet` | A mainnet account | | Default RPC | `https://rpc.testnet.fastnear.com` | `https://rpc.mainnet.fastnear.com`, then `https://rpc.intea.rs` | | Explorer | `testnet.nearblocks.io` | `nearblocks.io` | The first vaults are recorded in `contracts/deployments/testnet.json` and `contracts/deployments/mainnet.json`. The tokens, the Intents account, the factory, the default RPCs and the explorers come from one map in the code, which the SDK, the CLI and the web console all read: `packages/core/src/constants.ts` ```ts export const NETWORKS = { mainnet: { rpcUrls: ["https://rpc.mainnet.fastnear.com", "https://rpc.intea.rs"], // intea answered on 2026-10-21 but ran nearcore 2.14.0-rc.2; S3 confirms. Configurable via NEAR_RPC_URLS usdc: "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", // native USDC, 6 decimals intents: "intents.near", allowedAssets: { base: "base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", // POA USDC on Base, 6 decimals solana: "sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near", // POA USDC on Solana, 6 decimals (stretch) }, factory: "vaults.probarion.near", explorerTx: "https://nearblocks.io/txns/", }, testnet: { rpcUrls: ["https://rpc.testnet.fastnear.com"], usdc: "usdc.probarion.testnet", // test-ft deployment intents: "intents-stub.probarion.testnet", // intents-stub deployment (Intents has no testnet) allowedAssets: { base: "base-usdc.probarion.testnet", solana: "sol-usdc.probarion.testnet" }, // test-ft deployments acting as POA tokens factory: "vaults.probarion.testnet", explorerTx: "https://testnet.nearblocks.io/txns/", }, } as const; ``` ## What is different on testnet - **The tokens are test tokens.** Test USDC and the Base and Solana assets are deployments of `test-ft`, a test token contract. Only the token's owner account, `probarion.testnet`, can mint it. Any other caller gets `test-ft: only the owner can mint`. - **Test USDC comes from the faucet.** On a testnet vault's Top-up tab (`/console//topup`), the "Add test USDC" button mints 25 test USDC into the vault. Only the vault's owner can use it. No wallet signs: the Probarion team's key signs the mint, and that key can only mint the test token. It works once a UTC day per vault, and for up to 3 vaults per signed-in account a day. When the server has no faucet key, the tab shows this instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." - **NEAR Intents is a stand-in.** Intents has no testnet, so `intents-stub.probarion.testnet` takes the place of `intents.near`. For a payout to a Base or Solana payee, the stub lowers the vault's balance in its own records and logs the burn events, `mt_burn` and then `ft_burn` with the `WITHDRAW_TO:` memo the vault built from the mandate. No token is burned: the test tokens stay in the stub's account on the token contract, and nothing leaves NEAR. - **There is no Base top-up.** In the console's words, "NEAR Intents and 1Click exist on mainnet only", so the Top-up tab cannot top up a Base balance on testnet. The mainnet version of that top-up has not been tried with a live quote yet. - **The protocol versions can differ.** On 2026-11-01 testnet ran NEAR protocol 88 and mainnet ran 86. On 2026-11-11 both ran 88, as `contracts/deployments/testnet.json` records. A testnet pass does not prove mainnet behaviour for anything that changes between the two. - **The first vault runs an older build.** `v0.probarion.testnet` runs a build older than the current source, from before a `set_ledger_key` fix on 2026-11-03. The vaults the testnet factory creates run the fixed build recorded in `contracts/code-hashes.json`. The first vault's test spends, from the smoke run of 2026-11-01, were also sent without a ledger, so the verifier fails [check d](https://probarion.xyz/docs/trust/verifier-checks.md#check-d) and [check h](https://probarion.xyz/docs/trust/verifier-checks.md#check-h) on it today. Check d ties each spend to one decision in the ledger, and check h matches each burned spend with its burn. ## How the site picks a network From the address you opened, and from nothing else. 1. **You open an address** The site reads the host of the request: the first `X-Forwarded-Host` value, else `Host`. It never reads a query parameter, a cookie or the request body to pick a network. 2. **The address names the network** An address equal to a network's configured origin is that network. Every other address, including `probarion.vercel.app`, previews and `localhost`, is the default network, which is testnet. 3. **Everything follows that network** Sign-in, the vault pages, a vault's Alerts tab and `/usage` each work on that one network. One exception: the landing's live check and its links to the smoke run always read NEAR testnet, whatever network the page is on. The watcher and metrics runs are not tied to an address: each run covers every served network in turn and keeps each network's records apart. What that means when you use the console: - **A vault of the other network does not open here.** Signed in as a member of the vault, you see its page say "This vault is on NEAR" and the vault's network, with a link to that network's address when the site serves it, and the API answers [409 `wrong_network`](https://probarion.xyz/docs/build/errors.md#http-409-wrong_network) with one of the two sentences below. Anyone else gets the same answer as for a vault that does not exist, so a stranger is never told which network a vault is on. - **A registered vault keeps its network.** Registering it again at the other network's address is refused. A member of the vault, whatever their role, gets 409 and the same sentence as a vault page (below), before anything reads the chain. If two registrations race, the database refuses the second with "This vault is already registered on the other network. A vault stays on the network it was registered on." - **A session belongs to its address.** It names the network you signed in on. Signing in at one network's address does not sign you in at the other, and there you sign in with a wallet of that network. The message your wallet signs names `probarion.xyz` as its recipient on every address. - **The console checks the chain before sign-in and Register.** Before either creates lasting state, it asks each RPC URL which chain it serves, and the owner's Settings button "Re-sync approvers from the chain" runs the same check. A URL that serves the other chain stops them: sign-in answers [503 `wrong_chain`](https://probarion.xyz/docs/build/errors.md#http-503-wrong_chain) and makes no session, and Register shows "This site is connected to the wrong NEAR network right now, so nothing was registered. Try again later." - **The network switch stays hidden today.** The header shows its Testnet and Mainnet switch only when the other network is served, so today it does not show. The `wrong_network` answer carries the first sentence when the site serves the vault's network, and the second while it has no address for it: ```text This vault is on NEAR . Open it at the address. This vault is on NEAR , which this site does not serve yet. ``` ## For developers: match the network The SDK and the CLI take the network as a setting. The vault id and the RPC URLs you use must belong to that same network: a testnet vault ends in `.testnet`, a mainnet vault in `.near`. - **SDK.** `new Probarion(...)` takes `network: "testnet"` or `network: "mainnet"`. Any other value throws a `ConfigError`. The RPC comes from the `rpcUrls` option, else `NEAR_RPC_URLS`, else the network's defaults in the table above. - **CLI.** `--network` falls back to `NEAR_NETWORK`, then `testnet`. `--rpc` takes comma-separated URLs and falls back to `NEAR_RPC_URLS`, then the network's defaults. Put both options on the command line and no environment variable can change the network or the RPC. `verify` still reads its archival RPC, for burn transactions older than about 2.5 days, from `NEAR_ARCHIVAL_RPC_URLS` unless you pass `--archival-rpc`. A testnet run: ```bash node packages/cli/dist/bin.js --network testnet --rpc https://rpc.testnet.fastnear.com verify --vault v0.probarion.testnet ``` On this vault the run ends in `RESULT: FAIL` today, for the reason given above: its test spends of 2026-11-01 were sent without a ledger, so checks d and h fail. [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md) walks through this run and its output. > **NEAR_RPC_URLS is not per network.** > > The SDK and the CLI read `NEAR_RPC_URLS` and `NEAR_ARCHIVAL_RPC_URLS` for whichever network you pass, and the CLI reads `PROBARION_FACTORY_ID` and `PROBARION_VAULT_ID` the same way. The repository's `.env.example` sets all of them to testnet values: > > ```text > NEAR_NETWORK=testnet > NEAR_RPC_URLS=https://rpc.testnet.fastnear.com > NEAR_ARCHIVAL_RPC_URLS=https://archival-rpc.testnet.fastnear.com > PROBARION_FACTORY_ID=vaults.probarion.testnet > PROBARION_VAULT_ID=v0.probarion.testnet > ``` > > Nothing in the packages asks an RPC which chain it serves, and the CLI checks only that a factory or vault id is a well-formed account id. So a mainnet run with these values loaded talks to testnet, `vault create` prints a call to the testnet factory, and a command without `--vault` aims at `v0.probarion.testnet`. That follows from the code, and no test shows it yet. The `_MAINNET` and `_TESTNET` forms in the same file are the web console's settings: the SDK and the CLI never read them. > > For a mainnet run, pass `rpcUrls` or `--rpc`, or set `NEAR_RPC_URLS` to mainnet URLs. Also set `NEAR_ARCHIVAL_RPC_URLS` to a mainnet archival URL, or leave it unset so the network's FastNEAR archival node is used. `verify` also takes `--archival-rpc`. The SDK reads `NEAR_ARCHIVAL_RPC_URLS`, and a host can pass its own archival client instead, as the second constructor argument: `new Probarion(opts, { archivalRpc })`. Unset `PROBARION_FACTORY_ID` and `PROBARION_VAULT_ID` or set them to mainnet accounts, and pass `--vault`. ## The mainnet vault On 2026-11-11 the owner ran the small demo path of the [mainnet runbook](https://github.com/probarion/probarion/blob/main/docs/runbooks/mainnet-v0.md), with real USDC and small amounts. The owner signed every transaction in their own terminal and wallet. `v0.probarion.near` is a standalone vault: its owner `probarion.near` created it, and it deployed the recorded build `3cQXYDvfGLRMveCVAHmh1exiuZKDXWqD8cUhqdEs9MF1` and called `new` itself, with no factory. It has no full-access key. Its one approver, `probarion-duong.near`, is the owner's own wallet, which is also the payee of its mandate. - **One NEAR payment.** Under mandate 1, spend 1 paid 1 USDC on NEAR ([transaction](https://nearblocks.io/txns/22oqhgruo7JqdpPg5Cb2ts25mLK2fto8y1YAxNA56ieE)). - **One refusal.** A spend against mandate 999, which does not exist, was refused on chain with `PROBARION:NoMandate` ([transaction](https://nearblocks.io/txns/2Dabq4uMh9VvttH3uj85samfYoX2wbQheADHS96AtwYh)). - **No Base payout.** The runbook's Intents top-up, Base mandate and first Base payout did not run. Spike S1, a contract account withdrawing Base POA USDC to a Base address on mainnet, is unproven, so no Base payout has run on mainnet. - **A pre-ledger decision.** The vault has no ledger. Its spend's decision hash is the sha256 of a decision file in the repository, not an entry of a signed ledger, and no TEE attestation covers it. A live `verify` of the vault, with the repository's evidence and a mainnet RPC, passes checks a to h and skips check i, because the vault has no factory: see [its output of 2026-11-11](https://github.com/probarion/probarion/blob/main/docs/evidence/mainnet-v0/verify-2026-11-11.txt). Run it from a clone: ```bash node packages/cli/dist/bin.js --network mainnet --rpc https://rpc.mainnet.fastnear.com verify --vault v0.probarion.near --evidence docs/evidence --proof docs/mainnet-proof.json ``` [`docs/evidence/mainnet-v0`](https://github.com/probarion/probarion/tree/main/docs/evidence/mainnet-v0) lists every transaction of the run. No console or proof page serves this vault: the site serves testnet only. ## When mainnet opens All of this is Planned. - **The rest of the v0 run.** The runbook's Base payout through NEAR Intents waits for spike S1. The owner runs every step on their own machine: the owner calls are signed in the owner's own wallet, and the spends with the vault's agent key file. - **The mainnet factory.** Until `vaults.probarion.near` is deployed, the console cannot create a mainnet vault. Registering a vault that runs a recorded build, as `contracts/code-hashes.json` lists them, does not read the factory, but until a mainnet address is served the console cannot open or register a mainnet vault either. - **The addresses.** The plan is that `probarion.xyz` serves mainnet and `testnet.probarion.xyz` serves testnet. Testnet sessions on the main address then end, and people sign in again at the testnet address. Neither change is live. - **The docs.** Before the main address serves mainnet, the pages that say only testnet is live and the rules for agents are revised, and the site is built again: the Markdown twins and the llms files carry absolute links on the production address. - **The record.** [`contracts/deployments/mainnet.json`](https://github.com/probarion/probarion/blob/main/contracts/deployments/mainnet.json) and [`docs/mainnet-proof.json`](https://github.com/probarion/probarion/blob/main/docs/mainnet-proof.json) record the v0 run of 2026-11-11. Their Base, Intents and factory fields stay empty until those steps run. ## Where to go next - [What works today](https://probarion.xyz/docs/start/what-works-today.md) gives the status of every part. - The [owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md) sets up a testnet vault in the console, with test USDC from the faucet. - The [developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md) runs one payment cycle against a testnet vault with the SDK. - [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md) runs the verifier on `v0.probarion.testnet` and shows why checks d and h fail there. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) covers who signs what. - The [security model](https://probarion.xyz/docs/trust/security-model.md) covers what is proven and what is not, and the [glossary](https://probarion.xyz/docs/resources/glossary.md) defines Probarion's terms. ## What this does not do - **A testnet result is not a mainnet result.** The protocol versions can differ, and NEAR Intents is a stand-in. On mainnet only the small v0 run is recorded, with no Base payout. - **Test tokens have no value.** A testnet result shows how the contract, the SDK and the console behave. It moves no real money, and the test USDC in a vault cannot pay a real invoice. - **The site never moves a vault between networks.** Neither the address nor a setting changes the network a vault was registered on. - **No mainnet address is served yet.** The addresses under [When mainnet opens](#when-mainnet-opens) are the plan, not a deployment. - **The SDK and the CLI do not check the RPC's chain.** The console does, for sign-in and Register. In your own code, the network, the vault id, the factory and the RPC URLs must agree. --- # What works today > What is live, in preview or planned in Probarion today, from the same status list as the home page, plus more parts that list leaves out. The same list as the home page, from one source. Each row is Live (works in this deployment), Preview (built, not yet run end to end with real wallets or providers, or not yet open) or Planned (not built). Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/start/what-works-today NEAR testnet is live. Where a Live part runs on a chain, it runs on NEAR testnet with test tokens, except the mainnet vault. On mainnet one small vault, `v0.probarion.near`, has run since 2026-11-11 with real USDC: one NEAR payment and one refusal. No Base payout has run on mainnet, and the mainnet factory `vaults.probarion.near` is not deployed. [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md) says what differs between the two networks. ## The status list | Feature | Status | Detail | | - | - | - | | Vault contract | Live | On testnet | | Offline verifier, checks a to i | Live | Shipped, on npm | | Reproducible builds | Live | Shipped | | Vault factory | Live | On testnet | | Web console: vault, keys, faucet, NEAR USDC top-up, mandate, Payables, Agent (MCP) tab, Freeze, approvals, Reject, revoke, withdraw, key removal, proof page | Live | Tried on testnet | | Web console: email alerts, Base top-up | Preview | Built, not tried yet | | Public proof page | Live | Live for the testnet demo vault | | TypeScript SDK and CLI | Live | 0.1.1 on npm | | Payables agent | Live | Live on testnet | | Live attested model run | Preview | Checked online on testnet, image not pinned | | Canary public challenge | Live | Live on testnet | | MCP server | Live | 0.1.1 on npm, run by a real MCP client on testnet | | Mainnet vault | Live | On mainnet: 1 payment, 1 refusal, no Base payout yet | The Detail column is the landing page's own wording for each row. Each row's id is its anchor here. In the order of the rows, the ids are `vault-contract`, `offline-verifier`, `reproducible-builds`, `vault-factory`, `web-console`, `web-console-not-tried`, `public-proof-page`, `sdk-and-cli`, `payables-agent`, `attested-model-run`, `canary`, `mcp-server` and `mainnet-vault`, so [`#mainnet-vault`](#mainnet-vault) links the last row. The web console has two rows: what was tried on testnet with a real wallet, and the console actions that are built but not tried yet, adding an email alert and the Base top-up through 1Click, which is mainnet only. Reject on a held spend, revoking a mandate, Withdraw and removing an agent key moved to the first row on 2026-11-12, when the owner of a testnet vault tried each with a real wallet (Meteor), in a desktop browser ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md#2026-11-12-reject-revoke-a-spare-agent-key-and-withdraw-all)). Under its list, the landing page lists its known gaps, and the beta strip on every page links them. One is the contract build: "It also runs a build from before the 2026-11-03 `set_ledger_key` fix. The vaults the testnet factory creates, the demo vault among them, run the fixed build recorded in `contracts/code-hashes.json`." The vault source changed after `v0.probarion.testnet` was deployed, with a `set_ledger_key` fix on 2026-11-03. On the older build, replacing the ledger key could fail in a way that left the vault unable to anchor again. Every vault the testnet factory creates, the console's included, runs the fixed build. [`contracts/code-hashes.json`](https://github.com/probarion/probarion/blob/main/contracts/code-hashes.json) records the hash of each reproducible build, and [verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md) shows how to run the verifier on a vault yourself. > **Unaudited beta.** > > No third party has audited this code. [SECURITY.md](https://github.com/probarion/probarion/blob/main/SECURITY.md#what-is-not-proven-yet) lists what `verify` does not prove yet, and the [security model](https://probarion.xyz/docs/trust/security-model.md) covers the rest. ## Also in the source The status list leaves these parts out, among others. Each one follows one row of the [README Status table](https://github.com/probarion/probarion/blob/main/README.md#status) and has its own section below, with the code it links. | Part | Status | | - | - | | [NEAR AI Cloud adapter](#near-ai-cloud-adapter) | Preview | | [npm release 0.1.1](#npm-release-011) | Live | | [Test USDC on testnet](#test-usdc-on-testnet) | Preview | | [Browser extension, server half](#browser-extension-server-half) | Preview | | [Browser extension for Chrome](#browser-extension-for-chrome) | Preview | | [Testnet and mainnet from one deployment](#testnet-and-mainnet-from-one-deployment) | Preview | | [Owner alerts by Telegram and email](#owner-alerts-by-telegram-and-email) | Preview | | [Watcher](#watcher) | Preview | | [Usage page](#usage-page) | Preview | | [Health checks and the status page](#health-checks-and-the-status-page) | Preview | | [Error reports to Sentry](#error-reports-to-sentry) | Preview | The README Status table also has rows that this page does not cover: the incident runbook, the threat model and the security contact, which are documents in the repository ([`docs/RUNBOOK.md`](https://github.com/probarion/probarion/blob/main/docs/RUNBOOK.md), [`docs/THREAT_MODEL.md`](https://github.com/probarion/probarion/blob/main/docs/THREAT_MODEL.md), [`SECURITY.md`](https://github.com/probarion/probarion/blob/main/SECURITY.md)); the [/security](https://probarion.xyz/security), [/terms](https://probarion.xyz/terms) and [/privacy](https://probarion.xyz/privacy) pages, built and tested; and a browser smoke test of the built site ([`apps/web/e2e/smoke.spec.ts`](https://github.com/probarion/probarion/blob/main/apps/web/e2e/smoke.spec.ts)), which runs locally only and is not in CI. ### NEAR AI Cloud adapter Preview. Built and tested against a fake NEAR AI Cloud. On testnet, the Canary's ledger of 2026-11-10, published in the repository, holds two real attested decisions that pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/README.md#the-signed-ledger-published-2026-11-11), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/verify-online-2026-11-11.txt)). On the testnet demo vault, Payables made five more, which pass A1 to A7 offline; four of them, entries 10 to 13, also passed O1 and O2 online on 2026-11-12. Code refused three of them on 2026-11-11, and on 2026-11-12 the code allowed the fourth and the fifth, both by `Qwen/Qwen3.8-27B`, and the vault paid each invoice, 2.00 test USDC, as spends 4 and 5 ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-online-2026-11-12.txt)). The TEE measurement is not pinned, so this does not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-a8-2026-11-12.txt)). The [attested model run](#attested-model-run) row above is Preview too. Code: [`packages/sdk/src/nearai.ts`](https://github.com/probarion/probarion/blob/main/packages/sdk/src/nearai.ts) ### npm release 0.1.1 Live. On 2026-11-11, release 0.1.1 of `@probarion/core`, `@probarion/sdk`, `probarion`, the CLI, and `@probarion/mcp`, the MCP server, was published to npm under the `latest` tag. It came from the tag `sdk-v0.1.1` on `main`, through the release workflow, with npm provenance (SLSA v1). It is the first release of `@probarion/mcp`, which the official MCP registry lists as `io.github.probarion/probarion`. Release 0.1.0, of the other three, came out on 2026-11-09. [Install the packages](https://probarion.xyz/docs/build/install.md) has the install lines, and the build from a clone that the evaluation uses. Code: [`.github/workflows/release.yml`](https://github.com/probarion/probarion/blob/main/.github/workflows/release.yml) ### Test USDC on testnet The testnet faucet is Preview: it is built and tested, and its key is set on probarion.xyz. On 2026-11-09 its button minted 25 test USDC into the testnet demo vault, `demo.vaults.probarion.testnet`. On a testnet vault's Top-up tab (`/console//topup`), signed in as the vault's owner, the "Add test USDC" button mints 25 test USDC into the vault, with no wallet: the Probarion team's key signs the mint, and that key can only mint the test token. It works once a UTC day per vault, and for up to 3 vaults per signed-in account a day. A cap of 50 mints a UTC day for all vaults together can also stop it until midnight UTC, and the button then answers "Today's test USDC for all vaults is given out." with the time to wait. An approver or a viewer who opens the tab sees no button, only "Only this vault's owner can top it up." Without a faucet key on the server, the tab shows instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." Code: [`apps/web/src/lib/topup/faucet.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/topup/faucet.ts) ### Browser extension, server half Preview. Built and tested. The signed-in `/extension` page connects a browser for one network, and `GET /api/ext/inbox` lets that connection read what waits for its account: invoices waiting for the owner, holds an approver can still approve, and the mandates an invoice can still be sent under. `POST /api/ext/invoices` lets it queue an invoice under one of those mandates, for a vault where the account is an owner or an approver, at most 20 in 10 minutes: the invoice waits in the console for the owner's wallet-signed Confirm. A connection cannot pay, approve or confirm. Code: [`apps/web/src/lib/ext`](https://github.com/probarion/probarion/tree/main/apps/web/src/lib/ext) ### Browser extension for Chrome Preview. Version 0.1.0 is published: install it from the [Chrome Web Store](https://chromewebstore.google.com/detail/probarion/hhadnmnjdlamihjjoejphkimnbcfidmn). No end-to-end run of it with a real wallet against this deployment is recorded yet, so it stays Preview, like the server half it calls. To build it from source instead, load it unpacked from `apps/extension`, as [its README](https://github.com/probarion/probarion/blob/main/apps/extension/README.md) says. It connects from the `/extension` page or with a one-time code, shows on its toolbar badge and in its popup what waits for you, and queues an invoice from selected text, the PDF in the tab, a linked PDF on the same site, or a file, through the server half above. It cannot pay, approve or sign: a queued invoice waits for the owner's wallet-signed Confirm in the console. Code: [`apps/extension`](https://github.com/probarion/probarion/tree/main/apps/extension) ### Testnet and mainnet from one deployment Preview. Built and tested. The address a request was opened at sets its network, and the console's invoice and top-up APIs answer `409 wrong_network` for a vault of the other network, except the testnet faucet, which answers `404 not_found` so that a vault id is never confirmed. Only the testnet address is live. Code: [`apps/web/src/lib/network.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/network.ts) ### Owner alerts by Telegram and email Preview. Built and tested against a fake Telegram and a fake Resend. Telegram is on at probarion.xyz: on 2026-11-11 the owner of a testnet vault linked a chat from its Alerts tab, and the bot sent real messages, "Approval needed" for a held spend and "Paid" once an approved spend paid ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md)). No email has been sent yet. Each channel switches on by itself. Telegram needs `TELEGRAM_BOT_TOKEN` set and the bot's webhook registered by the setup script, since a chat links through that webhook. Email needs `RESEND_API_KEY` set and `probarion.xyz`, the domain the alerts are sent from, verified in Resend. The runbook [`docs/ops/alerts.md`](https://github.com/probarion/probarion/blob/main/docs/ops/alerts.md) has the setup. Code: [`apps/web/src/lib/alerts`](https://github.com/probarion/probarion/tree/main/apps/web/src/lib/alerts) ### Watcher Preview. Built and tested against a fake chain. For each vault registered with the app, it reads the spends, mandates and anchors from the chain, and the ledger, and records them for `/usage` and the alerts. It runs once a day on Vercel and right after an invoice upload or a Canary attempt. Its runbook [`docs/ops/watcher.md`](https://github.com/probarion/probarion/blob/main/docs/ops/watcher.md) also sets out a per-minute schedule through Supabase Cron. The Telegram alerts of 2026-11-11 point to a schedule of about a minute running that day: each came within two minutes of its event, and no invoice upload on that vault started the watcher ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md)). The schedule itself has not been read in Supabase. Code: [`apps/web/src/lib/watcher`](https://github.com/probarion/probarion/tree/main/apps/web/src/lib/watcher) ### Usage page Preview. Built and tested. [/usage](https://probarion.xyz/usage) shows counts, not dollar volumes, refreshed at most hourly while people view it and once a day by a job, for the network of the address it is opened at. Code: [`apps/web/src/app/usage`](https://github.com/probarion/probarion/tree/main/apps/web/src/app/usage) ### Health checks and the status page Preview. Built and tested. [/status](https://probarion.xyz/status) shows the health checks live for its network. The uptime monitors are not set up yet. Code: [`apps/web/src/lib/health.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/health.ts) ### Error reports to Sentry Preview. Built and tested with the real SDK sending nothing. Off until `SENTRY_DSN` is set. Code: [`apps/web/src/lib/sentry-scrub.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/sentry-scrub.ts) ## What does not exist None of these is on the status list as Planned. The README's known limitations, such as no Content-Security-Policy yet, and the threat model's [known gaps](https://github.com/probarion/probarion/blob/main/docs/THREAT_MODEL.md#known-gaps) list the rest. ### Outbound webhooks The code has no webhook that calls a server of yours. Its one webhook is incoming: Telegram calls `/api/telegram/webhook` for the alert bot. To follow a vault yourself, index the NEP-297 events its contract logs (`EVENT_JSON:` lines with the standard `probarion`). ### A public HTTP API for agents The console's invoice API answers only a signed-in owner or approver, on a same-origin request. An agent pays through the SDK, in its own process: [let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md) shows how. The other agent interface is the [MCP server](#mcp-server), which runs beside the agent over stdio and pays through the same SDK. ### Pricing There is no price list and no paid plan. The [terms](https://probarion.xyz/terms) say "Probarion charges nothing for the console." Your wallet pays for the transactions you sign and for the storage deposits: when you create the vault, and for each mandate. The agent's spends and the ledger key's anchors burn gas from the vault's own NEAR, up to each key's allowance, so the vault needs some NEAR of its own. [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) explains the allowances. ### A third-party audit No third party has audited this code, as the note under the status list says. [SECURITY.md](https://github.com/probarion/probarion/blob/main/SECURITY.md#reporting-a-vulnerability) says how to report a vulnerability. ## How this list stays true The status list is `STATUS.rows` in [`apps/web/src/lib/site/facts.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/site/facts.ts), the module the landing page renders. The docs home and this page render the same rows and turn each row's tone into a docs word: `live` becomes Live, `work` becomes Preview and `plan` becomes Planned. Tests hold the list to the repository: - [`apps/web/test/site-facts.test.ts`](https://github.com/probarion/probarion/blob/main/apps/web/test/site-facts.test.ts) holds each row to the words of its README Status row, and pins the exact rows, ids and pills. A row cannot change unless that test changes with it. - [`apps/web/test/docs-reference.test.ts`](https://github.com/probarion/probarion/blob/main/apps/web/test/docs-reference.test.ts) checks that every row maps to Live, Preview or Planned and has a stable id, the anchor of its row on this page. - [`apps/web/test/docs-pages.test.ts`](https://github.com/probarion/probarion/blob/main/apps/web/test/docs-pages.test.ts) checks that this page renders the table. The sections under [also in the source](#also-in-the-source) and [what does not exist](#what-does-not-exist) are written by hand, so no test holds them to the README. Where the two differ, the README Status table and the code it links are the record. ## Related pages - [What is Probarion](https://probarion.xyz/docs/start/what-is-probarion.md): the parts, and who does what. - [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md): what differs between the two networks. - [Install](https://probarion.xyz/docs/build/install.md): install the SDK and the CLI from npm, or build them from source. - [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md): run the verifier on a vault yourself. - [Security model](https://probarion.xyz/docs/trust/security-model.md): the security design in one page. --- # Vaults, roles and keys > Who can do what on a Probarion vault: the owner, approvers, spend-only agent keys and the anchor-only ledger key, and why most owner calls need 1 yoctoNEAR. A vault answers to its owner, its approvers and two kinds of function-call key on its own account: agent keys that can call only spend, up to 16 of them, and at most one ledger key that can call only anchor. A vault the factory creates holds no full-access key, and neither does v0.probarion.testnet. Status: Live · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/vaults-roles-and-keys The owner controls a vault from their own wallet, and approvers decide the spends it holds. A vault holds up to 16 agent keys and at most one ledger key, each a function-call key on the vault's own account limited to one method: `spend` for an agent key, `anchor` for the ledger key. Each owner and approver method that moves money or changes a key needs exactly 1 yoctoNEAR, a deposit that only a full-access key can attach, so neither a stolen agent key nor a dapp login key can take control of the vault. ## The vault in one paragraph A vault is a NEAR contract account of its own. It holds the owner's USDC, stores the owner's mandates and checks every spend against them before it pays. Whoever signs `create_vault` on the factory becomes the vault's owner, and the approvers are named in that same call. The vault adds keys to itself only when the owner tells it to: an agent key with `add_agent`, a ledger key with `set_ledger_key`. Its `get_config` view shows the owner, the approvers, the ledger key, whether the vault is frozen, and `factory`: the account that ran its init, which is the factory for a factory vault, or null for a vault that ran its own init, such as `v0.probarion.testnet`. > **No one can sign as the vault** > > A vault the factory creates has no full-access key: it starts with no access keys at all, and the factory owner cannot change, upgrade or take funds from it. `v0.probarion.testnet` was deployed directly, and its deploy script deleted its only full-access key, so its one access key is the agent's `spend`-only key. On these vaults nobody, Probarion included, can sign as the vault except through `spend` and `anchor`. [Check f](https://probarion.xyz/docs/trust/verifier-checks.md#check-f) fails any vault that still has a full-access key. ## Who can do what | Who | Can | Cannot | | - | - | - | | **The owner**, the account named as owner when the vault was created, signing with a full-access key | Create and revoke mandates, add and remove agent keys, set the ledger key, freeze, unfreeze, and withdraw the USDC and the NEAR Intents balance to the owner's own account | Withdraw to any other account, change a mandate's payee, change the owner or the approvers, or upgrade the vault: no method does any of these. Approve or reject a held spend, unless the owner is also on the approver list | | **An approver**, one of the 1 to 10 accounts named when the vault was created | Approve or reject a held spend, alone | Approve while the vault is frozen, after the hold expired, or for a revoked mandate. Change a mandate, a key or a setting | | **An agent key**, a function-call key on the vault, method `spend` only, up to 16 per vault | Call `spend` under a mandate that names this key | Choose the payee, go past the mandate's limits or the vault's daily backstop, call any other method, or attach a deposit | | **The ledger key**, a function-call key on the vault, method `anchor` only | Anchor the next range of the ledger, with `seq` exactly one above the last | Spend, or change a mandate or a key | | **Anyone** | Call `expire_held` once a hold has passed its time, with no deposit. Read the vault, dry-run a spend with `check_spend` and run the verifier | Call any other method that changes the vault | | **A dapp login key**, a function-call key on the owner's or an approver's account | On the owner's account, call `freeze`, if the key may call this vault | Call any method that needs a deposit: every other owner method, and `approve` and `reject` | A stolen agent key can therefore spend only under the mandates that name it, to the payees those mandates fix, within their limits and the daily backstop. Every call it signs, refused or not, also burns gas from the vault's NEAR, up to the key's allowance: 1 NEAR by default in the console and the CLI. `spend` has no field for an address, so the key cannot pick the recipient. [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md) explains that design, and the [security model](https://probarion.xyz/docs/trust/security-model.md) covers a breached server. One approver is enough: there is no M-of-N quorum. `approve` sends the payout at once. `reject` releases the reservation, and it still works while the vault is frozen or after the mandate was revoked. [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md) covers both. > **Approver signs** > > An approver signs `approve` or `reject` from their own wallet, with exactly 1 yoctoNEAR attached. The owner's signature does not count unless the owner is on the approver list: the vault answers [`NotApprover`](https://probarion.xyz/docs/build/errors.md#NotApprover). > > _An approver signs this transaction. Probarion never signs it._ ## The agent key An agent key is a function-call key that the vault adds to its own account when the owner calls `add_agent(public_key, allowance, label)`. Its receiver is the vault and its one method is `spend`: `contracts/vault/src/keys.rs` ```rust Promise::new(env::current_account_id()) .add_access_key_allowance( public_key.clone(), allowance, env::current_account_id(), "spend", ) .detach(); ``` Because the key lives on the vault account, the agent signs as the vault itself. `spend` refuses a call from any other account with [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent). - **Allowance.** The key carries a limited NEAR allowance for gas. The gas comes out of the vault's own NEAR, and the allowance caps how much of it this key can burn. An allowance of zero would mean no limit, so the vault refuses it with `InvalidInput`. The console and the CLI both default to 1 NEAR. - **Limits.** A vault holds at most 16 agent keys, and a key's label is at most 64 bytes. A key that is already an agent key or the ledger key is refused with `InvalidInput`. - **Mandates.** Each mandate names one agent key. A spend passes only if its signer is a current agent key (`NotAgent` otherwise) and is the key the mandate names (`NotMandateAgent` otherwise). [Mandates](https://probarion.xyz/docs/concepts/mandates.md) covers every other field. With the CLI, the owner prints the transaction and signs it in their own wallet: ```bash node packages/cli/dist/bin.js --network testnet agent add --vault --pk ed25519:... --label invoices ``` The command writes the arguments to `add_agent.<12 hex>.args.json` and prints the `near-cli-rs` command to sign. Illustrative, shortened: ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: near contract call-function as-transaction add_agent file-args ./add_agent.<12 hex>.args.json prepaid-gas '30.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as network-config testnet sign-with-keychain send add_agent arguments (./add_agent.<12 hex>.args.json): { "public_key": "ed25519:...", "allowance": "1000000000000000000000000", "label": "invoices" } The vault adds a function-call key to itself that can only call spend and can never attach a deposit. The vault holds at most 16 agent keys, and a key cannot be both an agent and the ledger key. ``` > **Owner signs** > > The owner signs `add_agent` from their own wallet, with exactly 1 yoctoNEAR attached. The transaction carries the agent's public key only, never its secret. > > _The owner signs this transaction. Probarion never signs it._ ## The ledger key The ledger key is another function-call key on the vault account, limited to `anchor`. The SDK signs every ledger entry with it, and after each payment cycle it calls `anchor` to pin the ledger's latest link on chain. [Ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md) explains the ledger itself. - **One per vault.** The owner sets it with `set_ledger_key(public_key, allowance)`, with exactly 1 yoctoNEAR. A new ledger key replaces the old one in the vault's config, and the vault deletes the old access key. - **Only the current key anchors.** `anchor` accepts a call only from the vault account, signed with the current ledger key. A call from any other account or key gets `NotLedgerKey`, and an agent key cannot reach `anchor` at all. - **Never an agent key.** `set_ledger_key` refuses an agent key with `InvalidInput`. It also refuses the current ledger key: this follows from the contract's code, and no test shows it yet. - **Allowance.** The console and the CLI both default to 0.25 NEAR. - **Old ranges stay checkable.** Each anchor stores the key that signed it, so a range anchored before a rotation stays verifiable after it. `v0.probarion.testnet` has no ledger key and no anchors yet: its smoke run sent spends with the agent key directly, without the SDK. The testnet demo vault, `demo.vaults.probarion.testnet`, has both keys: an agent key that can call `spend` only, and a ledger key that can call `anchor` only, which anchored its ledger five times on 2026-11-09. ## Why most owner calls need exactly 1 yoctoNEAR Most owner methods, and both approver methods, need exactly 1 yoctoNEAR attached, because the NEAR protocol lets only a full-access key attach a deposit. A function-call key, such as the login key a wallet creates when you sign in to a dapp, can call only its one receiver, and there only its listed methods, or any method when the list is empty. It can never attach a deposit. A call without the yoctoNEAR fails with [`RequiresOneYocto`](https://probarion.xyz/docs/build/errors.md#RequiresOneYocto), so only a full-access key, through a wallet prompt, can make it: `contracts/vault/src/errors.rs` ```rust /// The one-yocto rule (interfaces v2 §4.2): exactly 1 yoctoNEAR attached. Only a full-access key, /// through an explicit wallet prompt, can attach a deposit. A dapp-login Function-Call key, or an /// XSS in the web app, therefore cannot call a method that requires this. pub fn require_one_yocto() { if env::attached_deposit() != NearToken::from_yoctonear(1) { fail(ErrorCode::RequiresOneYocto) } } ``` | Method | Signed by | Deposit | | - | - | - | | `add_agent`, `remove_agent`, `set_ledger_key` | The owner | Exactly 1 yoctoNEAR | | `revoke_mandate`, `unfreeze`, `withdraw`, `withdraw_intents` | The owner | Exactly 1 yoctoNEAR | | `create_mandate` | The owner | A storage deposit. The vault sends back the excess | | `freeze` | The owner | None | | `approve`, `reject` | An approver | Exactly 1 yoctoNEAR | | `spend` | An agent key | None | | `anchor` | The ledger key | None | | `expire_held` | Anyone | None | `create_mandate` pays for the mandate's storage and, for a NEAR payee, the payee's USDC storage registration. A deposit that falls short is refused with `InsufficientDeposit`. A function-call key cannot attach that deposit either, so a login key cannot create a mandate. Reads such as `get_config` and `check_spend` are views and need no deposit. The vault checks who is calling before it checks the deposit, so a stranger who attaches nothing gets `NotOwner` or `NotApprover`, not `RequiresOneYocto`. ### Freeze is the exception `freeze` takes no deposit, so the owner can stop the vault fast. It works from the owner's full-access key, or from a function-call key on the owner's account that may call the vault: one whose receiver is the vault and whose method list is empty or names `freeze`, such as a dapp login key scoped to the vault. A login key scoped to another contract cannot call the vault at all. `contracts/vault/src/admin.rs` ```rust /// Protective, so it is the one owner method with no deposit: any key on the owner's /// account can stop the vault fast. `unfreeze` is the careful direction. pub fn freeze(&mut self) { self.assert_owner(); self.frozen = true; VaultEvent::Frozen { by: env::predecessor_account_id(), } .emit(); } ``` The comment's "any key on the owner's account" says too much. The NEAR protocol limits a function-call key to its receiver and its methods, so only a key that may call this vault can freeze it. The sandbox test signs `freeze` with a login key scoped to the vault. While the vault is frozen, every new spend and every `approve` is refused with `Frozen`. `reject` and `expire_held` still work. The owner can still change keys and withdraw, since none of those methods checks the freeze: this follows from the contract's code, and no test shows it yet. `unfreeze` needs exactly 1 yoctoNEAR, so only a full-access key can undo a freeze: a login key that froze the vault cannot unfreeze it. > **Owner signs** > > The owner signs `freeze` and `unfreeze`. `freeze` needs no deposit and may come from a login key that may call the vault. `unfreeze` needs exactly 1 yoctoNEAR, so it takes the owner's full-access key. > > _The owner signs this transaction. Probarion never signs it._ ## Where the keys live | Key | Where it lives | What signs with it | | - | - | - | | The owner's key | The owner's own wallet: a full-access key on the owner's account | Every owner transaction | | An approver's key | The approver's own wallet | `approve` and `reject` | | An agent key | Sealed on Probarion's server if the console made it. If `agent keygen` made it, in the file you named with `--out`, or wherever you put it after `--print-secret` | `spend`, through the SDK or Payables, the agent Probarion runs for you in the console | | The ledger key | The same places as an agent key | Every ledger entry and `anchor`, through the SDK | | A full-access key on the vault | Nowhere on a vault the factory creates, or on `v0.probarion.testnet`. Check f of the verifier fails any vault that has one | Nothing | **Keys made in the console.** The console's Keys tab generates agent and ledger keys on Probarion's server and stores each secret encrypted with libsodium secretbox, under a master key kept outside the database. You then add each key to your vault from your own wallet, with its public key only. Payables signs each payment with the agent key its mandate names. Probarion holds no owner key. The Keys tab is Preview. On 2026-11-12, on a testnet vault, it generated a spare agent key, the owner added it from their own wallet (Meteor), the tab listed it as active, and the owner then removed it on the same tab ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md#2026-11-12-reject-revoke-a-spare-agent-key-and-withdraw-all)). That key signed no payment, and that run did not make or replace a ledger key. **Keys made with the CLI.** `agent keygen` makes an ed25519 key pair on your machine. With `--out ` it saves the pair to a new file and prints only the public key. With `--print-secret` it prints the secret to your terminal, where it stays in the scrollback. It never overwrites a key file, and the same command makes a ledger key. ```bash node packages/cli/dist/bin.js agent keygen --out ~/probarion-keys/agent.json ``` The CLI warns when the file is inside a git repository, as any path inside the clone is. A relative path such as `../agent-key.json` keeps the file outside it. On Windows, file mode bits do not restrict the file, so keep it in a folder only you can read. Your agent passes the secret to the SDK as `agentKey` or `ledgerKey`, for example from `PROBARION_AGENT_KEY` or `PROBARION_LEDGER_KEY`. [Install](https://probarion.xyz/docs/build/install.md) builds the CLI from a clone. ## Replacing a key Both replacements below are owner transactions, and neither one touches the USDC in the vault. ### Remove an agent key With `remove_agent(public_key)`, the vault drops the key from its list and deletes its access key, so the key can sign no more spends. A key that is not on the list is refused with `InvalidInput`. If its access key is already gone, the vault still clears the list, and only the delete fails. > **Owner signs** > > The owner signs `remove_agent` from their own wallet, with exactly 1 yoctoNEAR attached. > > _The owner signs this transaction. Probarion never signs it._ What happens to the work already in flight: - **Held spends still wait for an approver.** Removing the key does not cancel them: an approver can still approve or reject each one, and anyone can expire it once its time is up. This follows from the contract's code, because `approve`, `reject` and `expire_held` do not look at the agent key. No test shows it yet. - **Mandates that name the key keep their status, but no spend can pass under them.** A spend needs a signature from that key, and a `check_spend` dry run for it answers `NotAgent`. A mandate's agent key cannot be changed. - **A removed key can be added again.** The vault accepts it as a new agent key. Mandates that name it should then work again: this follows from the contract's code, and no test shows it yet. The console has no one-click rotation of an agent key: generate a new key and add it, create new mandates that name it, then revoke the old mandates and remove the old key. Removing the old key first stops payments at once: Payables stops using it, and no spend passes under the mandates that name it. ### Rotate the ledger key The owner signs `set_ledger_key` with a new key. The vault records the new key at once, then deletes the old access key and adds the new one in two separate receipts, delete first. Deleting first frees the old key's storage before the new key is charged, so a rotation fits even on a vault with no free NEAR. A sandbox test rotates a key on exactly such a vault. This two-receipt, delete-first order is the `set_ledger_key` fix of 2026-11-03. Vaults the testnet factory creates run the fixed build, and `v0.probarion.testnet` runs the build from before it. > **Anchor the ledger before you rotate** > > Entries already covered by an anchor stay verifiable against the key recorded with that anchor. Entries after the last anchor must be signed by the new key, so an old-key entry that is not anchored yet fails the verifier's signature check after the rotation. First check that the ledger is anchored. The SDK anchors the ledger's tail at the end of each payment cycle, and a failed anchor is only logged, so the next cycle anchors the whole tail. Neither the SDK nor the CLI has a separate anchor command. The ledger is anchored when the `count` of the SDK's `anchors.latest()` equals the number of entries in your ledger. If it is lower, the next payment cycle, still signed with the old key, anchors the tail. Then the owner sets the new key. On the console's Keys tab, choose "Generate ledger key and set it in my wallet", or "Set as ledger key" on a ledger key generated earlier. With the CLI, the owner prints the transaction and signs it in their own wallet: ```bash node packages/cli/dist/bin.js --network testnet ledger set-key --vault --pk ed25519:... ``` > **Owner signs** > > The owner signs `set_ledger_key` from their own wallet, with exactly 1 yoctoNEAR attached. The transaction carries the new public key only. > > _The owner signs this transaction. Probarion never signs it._ Last, give the SDK the new key as its `ledgerKey`. The SDK refuses to write an entry when its ledger key is not the vault's. ### If the new ledger key does not land When the delete frees nothing, the new key's AddKey can fail with `LackBalanceForState` if the vault's free NEAR does not cover it, for example once agent gas has used up the vault's free NEAR. The delete frees nothing when there is no old key, as with a vault's first ledger key, and a sandbox test reaches this case. It also frees nothing when the old key's access key is already gone: this follows from the contract's code, and no test shows it yet. The vault's config then already names the new key, but the key is not on the account, so nothing can anchor. On the fixed build, to recover: 1. Top up the vault's NEAR with a plain transfer. 2. Call `set_ledger_key` again with a different new key. Setting the same key again is refused with `InvalidInput`, because it is already the recorded ledger key: this follows from the contract's code, and no test shows it yet. The delete of the missing key then fails on its own, with `DeleteKeyDoesNotExist`, and the new key lands. The console's Keys tab marks such a key as stuck, and on the fixed build it offers a new ledger key as the way out. ## Where to go next - [Owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md): set up a testnet vault in the console, with an agent key, a ledger key and one mandate. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): every field of the mandate an agent key spends under. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): what an approver decides, and when a hold expires. - [Ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md): what the ledger key signs and anchors. - [Security model](https://probarion.xyz/docs/trust/security-model.md): what a breached server or a leaked key can and cannot do. - [The agent key cannot approve itself](https://probarion.xyz/docs/trust/self-approval.md): the agent key against every other vault method that changes state, the USDC token, a NEAR transfer, a new full-access key and new code, in a test that replays the I-HITL paper's attack. - [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md): check f fails a vault with a full-access key, or with any key that is neither an agent key nor the current ledger key. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): the terms on this page. ## What this does not do - **It does not let anyone change the owner or the approvers, or upgrade the vault.** No method does. A fix ships as a new vault. - **It does not edit a mandate.** A mandate's payee and agent key are fixed when it is created. A new key needs new mandates. - **Removing an agent key does not cancel its held spends.** An approver still decides each one, or it expires. This follows from the contract's code, and no test shows it yet. - **It has no approval quorum.** One approver's `approve` pays at once. - **The one-yocto rule does not stop a login key from freezing.** `freeze` takes no deposit on purpose. Only `unfreeze` needs the full-access key. - **It cannot protect a stolen owner key.** The vault checks only that a call comes from the owner's account, so whoever holds a full-access key on that account acts as the owner. Its holder can withdraw to that account, unfreeze, and sign a mandate to any payee with an agent key of their own. The [security model](https://probarion.xyz/docs/trust/security-model.md) covers it. - **It does not hold the keys you make with the CLI.** `agent keygen` saves the secret to the file you name, or prints it to your terminal, and keeping it safe is up to you. - **The contract does not stop a vault deployed by hand from keeping a full-access key.** The factory adds none, and the verifier fails any vault that has one. --- # Why spend has no payee > The agent's spend request has no address field. The vault pays the payee the owner set in the mandate, so a hijacked prompt cannot pick where money goes. 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. Status: Live · Network: Testnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/why-spend-has-no-payee ## 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. 1. **The owner signs a mandate** The vault stores its payee, its limits and the one agent key that may use it. 2. **The agent sends a spend** It names a mandate, an amount, an invoice hash, a ref and a decision hash. No payee. 3. **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)** ```rust #[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)** ```ts 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: `contracts/vault/src/spend.rs` ```rust 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. > > _An approver signs this transaction. Probarion never signs it._ ### A NEAR payee The vault calls `ft_transfer` on its USDC token, to the mandate's `account_id`, with the memo `probarion:`. 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](https://probarion.xyz/docs/resources/glossary.md#poa-asset), the token inside NEAR Intents that stands for USDC on the payee's chain, with the memo `WITHDRAW_TO:
`. The vault builds that memo from the mandate alone: `contracts/vault/src/types.rs` ```rust impl Payee { /// `WITHDRAW_TO:
` for an Intents payee, built only from mandate storage (I7). pub fn withdraw_memo(&self) -> Option { 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`](https://testnet.nearblocks.io/txns/3DFV2thBU9V4b5w7kM691poLJ8SWiCqp46LKBn1Fy9cX) | `ft_transfer` of 1 test USDC with the memo `probarion:1` | | `0x1111…1111`, a Base address | [`3u4ffrwp…dcvYe`](https://testnet.nearblocks.io/txns/3u4ffrwpiwLoNV6cU72NHLVBAMq729NwLs6ixDLdcvYe) | `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](https://probarion.xyz/docs/resources/glossary.md#attestation), a signed report that the SDK checks. [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md) 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: `packages/core/src/types.ts` ```ts 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: `packages/core/src/proposal-request.ts` ```ts "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 `SpendRequest` from `packages/core/src/types.ts` and fails if any field name matches `/recipient|payee|address|receiver|destination/i`. A second test reads the contract's `SpendRequest` from `contracts/vault/src/types.rs` and fails unless its fields are the same five, in the same order. Both are in `apps/web/test/site-facts.test.ts`. - **The memo.** A sandbox test in `contracts/integration-tests/tests/flows.rs` creates a mandate for a Base address and spends 7 USDC of a test token under it. It checks that the burn carries exactly `WITHDRAW_TO:` and the mandate's address, on the allowed asset, and that `get_spend` shows the same memo. - **The decision.** A test in `packages/core/test/policy.test.ts` checks that `decide()` refuses whenever the invoice or the model names another payee, and that a decision has no destination field. - **The payee never changes.** `create_mandate` is the only vault method that takes a payee, and no method edits one. `withdraw` and `withdraw_intents` send only to the owner's own account. To pay someone new, the owner signs a new mandate, and can revoke the old one with `revoke_mandate` so it refuses new spends. The CLI's `mandate create` prints 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](https://probarion.xyz/docs/trust/verifier-checks.md#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_burn` whose memo is `WITHDRAW_TO:` and the mandate's address, and the vault's Intents `mt_burn` for the spend amount. A burn is `unproven` when 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. > > _The owner signs this transaction. Probarion never signs it._ ## Where to go next - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): every field of a mandate, the payee included. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md): who holds the agent key, and what it can call. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks every spend meets. - [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md): the whole cycle, from mandate to record. - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle from your agent. - [Security model](https://probarion.xyz/docs/trust/security-model.md): what each key can and cannot do. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): 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](https://probarion.xyz/docs/resources/glossary.md#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](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md#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. --- # Mandates > Every field of a mandate, what the contract checks when the owner signs one, why a mandate is never edited, and why an expired one can still read active. A mandate is the owner's signed, standing permission for one agent key to pay one payee, up to fixed limits, until a fixed time. Nobody can edit one: to change it, the owner signs a new one and revokes the old one. Status: Live · Network: Testnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/mandates Two rules matter most when you sign a mandate: its payee can never change, and its on-chain `status` never says expired, so an expired mandate can still read `active`. This page covers both, field by field. ## What a mandate is A vault holds USDC and any number of mandates. Each mandate names: - **one payee**: a NEAR account, or an address on Base or Solana paid through NEAR Intents; - **one agent key**: the only key that may spend under it; - **fixed limits**: the most per payment, the most in all, the most payments, and the time it expires; - **an approval threshold**: a spend above it waits for an approver; - **a purpose hash**: the sha256 of the purchase order or contract it pays against. The agent never names a payee. A spend names a mandate id, an amount, an invoice hash, a ref and a decision hash, and the vault reads the destination from the mandate in its own storage. The [ref](https://probarion.xyz/docs/resources/glossary.md#ref) is the caller's one-time key for that spend, and the [decision hash](https://probarion.xyz/docs/resources/glossary.md#decision_hash) is the hash of the signed decision in the ledger that allowed it. See [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). Only the vault's owner creates and revokes mandates, from the owner's own wallet. The agent key may call only `spend`, so it cannot create, change or revoke a mandate. See [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md). ## Every field The owner fills in the `MandateInput` that `create_mandate` takes. Verbatim, lines 39 to 51: `contracts/vault/src/types.rs` ```rust #[near(serializers = [json])] #[derive(Clone, Debug)] pub struct MandateInput { pub payee: Payee, pub purpose_hash: String, pub max_per_payment: U128, pub max_total: U128, pub max_uses: u32, pub expires_at_ns: U64, pub approval_above: U128, pub agent_pk: PublicKey, pub label: String, } ``` | Field | Type | Meaning | Checked at creation | | - | - | - | - | | `payee` | An object tagged by `type` | Who is paid. `near` has `account_id`. `intents` has `chain` (`base` or `solana`), `address` and `asset`. It never changes. | A NEAR payee is not the vault itself (`InvalidInput`). An Intents payee's `asset` is in the vault's `allowed_assets` and starts with its chain's prefix, `base-` or `sol-` (`AssetNotAllowed`). Its address is `0x` plus 40 hex digits for Base, or 32 to 44 base58 characters for Solana (`InvalidInput`). | | `purpose_hash` | String | The sha256 of the purchase order or contract. Only the hash goes on chain. The SDK picks the mandate for an invoice by this hash. | 64 lowercase hex (`InvalidInput`). | | `max_per_payment` | U128, a decimal string | The largest single payment. A payment equal to it passes. | Above 0 and at most `max_total` (`InvalidInput`). | | `max_total` | U128, a decimal string | The most the mandate pays in all. A new spend must fit under it together with `spent` and `reserved`. | At least `max_per_payment` (`InvalidInput`). | | `max_uses` | u32, a JSON number | The most payments. | Above 0 (`InvalidInput`). | | `expires_at_ns` | U64, a decimal string of nanoseconds | From this time on, the vault refuses every new spend. | After the block time (`InvalidInput`). | | `approval_above` | U128, a decimal string | A spend above this amount is held for an approver. A spend equal to it pays at once. | Not checked, and not compared with `max_per_payment`. | | `agent_pk` | A public key, `ed25519:...` | The one agent key that may spend under this mandate. | Not checked against the vault's agent keys. | | `label` | String | A name for people. | At most 64 bytes (`InvalidInput`). | Amounts are in USDC's smallest unit, 6 decimals, so `"5000000"` is 5 USDC. The codes are explained on [Errors](https://probarion.xyz/docs/build/errors.md#InvalidInput). Illustrative: the `input` argument of `create_mandate` for a NEAR payee and for a Base payee on testnet, with placeholder accounts, hash and key. The expiry is 2026-12-31 23:59:59 UTC, as the console writes it: `1798761599000000000`. The CLI's `--expires 2026-12-31` in step 3 below writes 23:59:59.999 UTC instead: `1798761599999000000`. **NEAR payee** ```json { "payee": { "type": "near", "account_id": "payee.testnet" }, "purpose_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "max_per_payment": "5000000", "max_total": "20000000", "max_uses": 5, "expires_at_ns": "1798761599000000000", "approval_above": "2000000", "agent_pk": "ed25519:...", "label": "First test" } ``` **Base payee** ```json { "payee": { "type": "intents", "chain": "base", "address": "0x1111111111111111111111111111111111111111", "asset": "base-usdc.probarion.testnet" }, "purpose_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "max_per_payment": "5000000", "max_total": "20000000", "max_uses": 5, "expires_at_ns": "1798761599000000000", "approval_above": "2000000", "agent_pk": "ed25519:...", "label": "Base contractor" } ``` On testnet, a stand-in contract takes the place of NEAR Intents, so a payout to a Base or Solana address does not leave NEAR. ## What the vault adds When a mandate is read with `get_mandate` or `list_mandates`, the vault returns the fields above and six more: | Field | Type | Meaning | | - | - | - | | `id` | u64 | Given in order, starting at 1, in each vault. Id 0 names nothing. | | `spent` | U128 | What spends that ended `paid` or `burned` have paid out. [`burned`](https://probarion.xyz/docs/resources/glossary.md#burned) is the status of a payout to a Base or Solana address once NEAR Intents accepts the withdrawal. Nothing lowers it. | | `reserved` | U128 | What spends that are `held` or `pending` hold back. When one settles, its amount moves to `spent`. When one is released, its amount comes back. | | `uses` | u32 | The spends that are `held`, `pending`, `paid` or `burned`. A `failed`, `rejected` or `expired` spend gives its use back. | | `status` | `active`, `revoked` or `exhausted` | Worked out from the other fields each time the mandate is read. See the status section below. | | `created_at_ns` | U64 | The block time of the `create_mandate` call. | What the mandate can still pay is `max_total` minus `spent` minus `reserved`. The console shows it as "Remaining / total". ## Creating a mandate Only the owner can create a mandate: any other caller gets `NotOwner`. The console's "Mandates" tab and the CLI's `mandate create` build the same `MandateInput`, and the owner signs it. In the console, the form appears once you are signed in as the vault's owner on chain and the vault has an agent key: see [Owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md). 1. **Prepare the input.** In the console, fill in the "New mandate" card on the "Mandates" tab. With the CLI, pass each field as a flag. `--purpose` takes the file, which it hashes with sha256, or its sha256 as 64 lowercase hex: the CLI does not lowercase a hash for you, and reads an uppercase one as a file name. `--expires` takes `YYYY-MM-DD`, which means the end of that UTC day (23:59:59.999), or an ISO-8601 time with its zone. 2. **Attach the deposit.** The console attaches 0.05 NEAR, and the CLI's `--deposit` defaults to 0.05. The vault keeps what it needs and sends the rest back to the owner. See [The deposit](#the-deposit). 3. **Sign as the owner.** In the console, click "Sign mandate in my wallet" and sign in your wallet. The CLI never signs: it writes the arguments to a file and prints the transaction for the owner to sign with their own wallet or near-cli-rs. ```bash # Illustrative values: use your vault, the payee, the owner's account and the agent's public key. node packages/cli/dist/bin.js --network testnet mandate create --vault acme.vaults.probarion.testnet \ --payee near:payee.testnet --max-per 5 --max-total 20 --uses 5 --expires 2026-12-31 \ --approval-above 2 --agent-pk ed25519:... --purpose ../po.txt --label "First test" \ --sign-as owner.testnet --out-dir probarion-tx ``` The printed transaction starts with this line: ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: ``` > **Owner signs** > > The owner's wallet signs `create_mandate` on the vault and attaches the deposit. > > _The owner signs this transaction. Probarion never signs it._ 4. **Read back the id.** `create_mandate` returns the new mandate's id and emits a `mandate_created` event. The console's "Mandates" table lists the mandate under `#` with the status `active`. The SDK reads it with `mandates.get(id)`, or every mandate with `mandates.list()`. ### The deposit The deposit the vault requires is the storage the new mandate takes, at NEAR's byte cost, plus 0.00125 NEAR when the payee is a NEAR account. - **A NEAR payee.** The vault passes the 0.00125 NEAR to the USDC contract's `storage_deposit` for the payee, as a registration only, so the payee can receive USDC. The vault does not wait for the result of that call. - **An Intents payee.** No registration is needed, so the deposit covers storage only. - **Too little.** The call fails with [`InsufficientDeposit`](https://probarion.xyz/docs/build/errors.md#InsufficientDeposit), and no mandate is stored. - **More than needed.** The vault sends the excess back to the owner, who made the call. ### The payee needs no NEAR A mandate's payee only receives USDC. It signs nothing and calls no method: the agent key sends `spend`, and the vault pays with its own `ft_transfer` of USDC, or its own NEAR Intents withdrawal for a Base or Solana payee. A NEAR payee's USDC registration comes out of the owner's `create_mandate` deposit, as above, so the payee needs no NEAR to be paid, and an Intents payee needs no NEAR account at all. The testnet demo vault shows it: the owner signed mandate 1 for `probarion-payee.testnet` in [`F8Qj4yNA...`](https://testnet.nearblocks.io/txns/F8Qj4yNAK8eTqTtjs7QbQuxMHCrm228jWNfGJHEiHpav), the `create_mandate_tx` of [`contracts/deployments/testnet.json`](https://github.com/probarion/probarion/blob/main/contracts/deployments/testnet.json), and the vault then paid that account twice. None of the transactions in its [evidence](https://github.com/probarion/probarion/tree/main/docs/evidence/demo-vault) is signed by the payee. Probarion runs no NEP-366 relayer, so nobody pays gas for anyone else: the owner and each approver pay the gas of every transaction they sign, from their own account, and the gas of the agent key and of the ledger key comes out of the vault's own NEAR. ### What the contract checks `create_mandate` runs these checks in order. The first one that fails refuses the call, and nothing is stored. 1. The caller is the owner, else `NotOwner`. 2. The shape, else `InvalidInput`: `purpose_hash` is 64 lowercase hex, `max_per_payment` is above 0 and at most `max_total`, `max_uses` is above 0, `expires_at_ns` is after the block time, and `label` is at most 64 bytes. 3. The payee. A NEAR payee that is the vault itself gets `InvalidInput`. For an Intents payee, an asset outside `allowed_assets`, or one without its chain's prefix, gets [`AssetNotAllowed`](https://probarion.xyz/docs/build/errors.md#AssetNotAllowed). Then an address in the wrong format for its chain gets `InvalidInput`. A Base address is `0x` in lowercase followed by 40 hex digits in any case. 4. The deposit, else `InsufficientDeposit`. Verbatim, lines 120 to 153: `contracts/vault/src/mandates.rs` ```rust fn validate_mandate(&self, m: &MandateInput) { let shape_ok = is_hex64(&m.purpose_hash) && m.max_per_payment.0 > 0 && m.max_per_payment.0 <= m.max_total.0 && m.max_uses > 0 && m.expires_at_ns.0 > env::block_timestamp() && m.label.len() <= 64; if !shape_ok { fail(ErrorCode::InvalidInput) } match &m.payee { Payee::Near { account_id } => { if *account_id == env::current_account_id() { fail(ErrorCode::InvalidInput) } } Payee::Intents { chain, address, asset, } => { if !self.allowed_assets.contains(asset) || !asset_matches_chain(*chain, asset) { fail(ErrorCode::AssetNotAllowed) } let address_ok = match chain { Chain::Base => is_evm_address(address), Chain::Solana => is_solana_address(address), }; if !address_ok { fail(ErrorCode::InvalidInput) } } } } ``` Two things are not checked at creation: - **`agent_pk` is not checked against the vault's agent keys.** A mandate for a key that is not an agent key yet is stored, but no spend under it can pass until the owner adds that key. Until then the spend check answers `NotAgent`. - **`approval_above` is not compared with `max_per_payment`.** A spend is held when its amount is above `approval_above`. So a threshold at or above `max_per_payment` means no spend is ever held, and a threshold of 0 holds every spend. The console's summary says which: "No payment is held for approval: every payment up to the per-payment cap is paid at once." or "Every payment is held for an approver's signature." ### What the console and the CLI check first > **The console is stricter than the contract** > > The console refuses, before you sign, some mandates the contract would accept. The rules below are the console's own, not the contract's. - The payee type is "NEAR account (USDC on NEAR)" or "Base address (USDC on Base via NEAR Intents)". The console does not offer a Solana payee. The contract and the CLI accept one. - A mixed-case Base address must match its EIP-55 checksum, and the zero address is refused. - A NEAR payee may not be the vault's USDC contract, its Intents contract or one of its allowed asset contracts: what is sent to a token contract is lost. - "Max number of payments" is at most 10,000 during the beta. The contract takes any u32 above 0. - "Expires on (UTC)" is at least one hour and at most 366 days ahead, and means the end of that UTC day (23:59:59). - "Agent key" must be one of the vault's active agent keys. Any other key gets "Pick an agent key that is active on this vault". - "Name" is not empty and is plain text, with no line break, control or direction character. The console also warns, without blocking, when the per-payment cap is above what the vault holds or above its [daily backstop](https://probarion.xyz/docs/resources/glossary.md#daily-backstop), the vault-wide cap on what spends may reserve or pay in one UTC day. The CLI checks the shape before it prints anything: `--max-per` above 0 and at most `--max-total`, `--uses` from 1 to 4294967295, a future `--expires`, a `--label` of at most 64 bytes, a payee in the right format, and a NEAR payee other than the vault. It checks only the agent key's format, not that the key is on the vault. ## Status: active, revoked, exhausted, and the expired trap The contract stores whether a mandate is revoked, and works out its `status` each time the mandate is read. Verbatim, lines 194 to 203: `contracts/vault/src/types.rs` ```rust impl Mandate { pub fn status(&self) -> MandateStatus { if self.revoked { MandateStatus::Revoked } else if self.uses >= self.max_uses || self.spent >= self.max_total { MandateStatus::Exhausted } else { MandateStatus::Active } } ``` | Status | Shown by | When | What a new spend gets | | - | - | - | - | | `active` | The chain and the console | Not revoked, a use left, and `spent` below `max_total` | Accepted if every other check passes, the expiry check included | | `revoked` | The chain and the console | The owner revoked it. This is final. | `MandateInactive` | | `exhausted` | The chain and the console | `uses` reached `max_uses`, or `spent` reached `max_total` | `UsesExhausted` or `OverMandateTotal` | | `expired` | The console only | The time is at or past `expires_at_ns`, and the mandate is not revoked | `MandateExpired` | Revoked wins over everything: a revoked mandate reads `revoked` even after it expires. The last column names the code the mandate's own state causes. The contract runs its checks in order, so an earlier one can fail first: `Frozen` on a frozen vault, or `MandateExpired` for an exhausted mandate that has also expired. See [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md). ### Expired still reads active The contract stores `expires_at_ns`, but `status` is worked out from `revoked`, `uses`, `max_uses`, `spent` and `max_total` and never reads the clock, so the on-chain `status` never says expired. Once the block time reaches `expires_at_ns`, the contract refuses every new spend, with `MandateExpired` unless an earlier check such as `NotAgent`, `InvalidInput` or `Frozen` fails first, while `status` still says `active` for a mandate that was active. The SDK's own `mandate.expired` rule reads `expires_at_ns`, never `status`. The console shows "expired" from that same time. > **Warning** > > The chain's `status` never says expired: an expired mandate keeps reading `active`, or `exhausted` or `revoked`. Check `expires_at_ns`, not `status`. Expiry stops new spends only. `approve` checks that the mandate is not revoked, and does not look at its expiry. So a spend held before the mandate expired can still be approved after it, until the hold itself expires. This is inferred: it follows from the code, and no test shows it yet. Revoking is what stops that: see [Revoking](#revoking). ### Exhausted is not always final `uses` counts held and pending spends too. A mandate whose last uses are held or pending reads `exhausted`. If one of those spends is released, because its payout failed, it was rejected, or its hold ran out and someone called `expire_held`, its use comes back and the mandate reads `active` again. A hold that runs out does not release itself: until `expire_held` is called, the spend stays held and keeps its use, so the mandate still reads `exhausted`. `spent` counts only payouts that settled, and nothing lowers it. A mandate that is exhausted because `spent` reached `max_total` stays exhausted. `status` also ignores `reserved`. A mandate with uses left whose remaining total is all held back by held or pending spends still reads `active`, and a new spend gets `OverMandateTotal`. The console's "Remaining / total" takes `reserved` into account. ## Revoking The owner revokes a mandate with `revoke_mandate`, which takes the mandate id and exactly 1 yoctoNEAR. A wrong deposit gets `RequiresOneYocto`, an unknown id gets `NoMandate`, and a mandate that is already revoked gets `MandateInactive`. Revoking emits a `mandate_revoked` event and cannot be undone. In the console, the vault's owner, signed in as the owner on chain, gets a "Revoke" button on a mandate's row in the "Mandates" table. An approver or a viewer sees none. The button's confirmation says: "This cannot be undone. The agent can no longer spend on it and a held payment can no longer be approved; a payout already pending still settles." With the CLI: ```bash # Illustrative values: use your vault, the mandate id and the owner's account. node packages/cli/dist/bin.js --network testnet mandate revoke --vault acme.vaults.probarion.testnet \ --mandate 1 --sign-as owner.testnet --out-dir probarion-tx ``` > **Owner signs** > > The owner's wallet signs `revoke_mandate` on the vault and attaches exactly 1 yoctoNEAR, as most owner calls do. `freeze` takes no deposit, and `create_mandate` takes a storage deposit and refunds the excess. > > _The owner signs this transaction. Probarion never signs it._ | What | After the revoke | | - | - | | A new spend | Refused with `MandateInactive`. | | A held spend | `approve` refuses it with `MandateInactive`. An approver can still reject it, and once its hold has expired anyone can call `expire_held`. Either one releases it. | | A pending payout | Keeps its reservation until its callback resolves, then settles as `paid` or `burned`, or is released as `failed`. | | The mandate | Reads `revoked` for good. | Revoking an expired mandate still matters while it has held spends, because `approve` does not look at the mandate's expiry: by the code, those spends can still be approved, though no test shows it yet. The console offers "Revoke" on an expired mandate only while part of it is still reserved, or when that figure cannot be read. While part is reserved, the note says: "Expired, but part of it is still reserved by held or pending payments. Revoking stops any held payments from being approved." When the figure cannot be read, it says: "Expired, and how much of it is still reserved could not be read. Revoking stops any held payments from being approved." It offers the owner "Revoke" on every mandate that is active or exhausted and not expired, and never on a revoked one. The code of `create_mandate` and `revoke_mandate` has no frozen check, so the owner can still create and revoke mandates while the vault is frozen. This is inferred: it follows from the code, and no test shows it yet. ## Why a mandate cannot be edited The vault has two owner methods for mandates: `create_mandate` and `revoke_mandate`. No method changes a field the owner signed, and none clears `revoked`. Only the spend flow moves `spent`, `reserved` and `uses`. A spend carries only a mandate id, and the vault reads the payee and the limits from that mandate when it pays. Because a mandate never changes, a mandate id always means the payee and the limits the owner signed. A decision in the ledger and a spend on chain both name the mandate id, so whoever checks them later reads the same payee and limits they were made under. > **Key idea** > > The payee never changes. To pay someone else, sign a new mandate. The old one keeps paying its payee until it is revoked, expires or runs out. To change a limit or the payee, sign a new mandate: it gets the next id and takes its own deposit. Nothing requires a revoke, so the old one keeps working until you revoke it, it expires or it runs out. If both cover the same purpose document, revoke the old one. For a purpose hash, the SDK picks the mandate with the lowest id that can still pay: one that is not revoked or expired, is bound to its agent key, and has a use and room left. While the old one can still pay, the SDK keeps picking it. ## Where to go next - [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks every spend meets, the mandate checks among them. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): what happens to a spend above `approval_above`. - [Owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md): sign a first mandate on testnet. - [Errors](https://probarion.xyz/docs/build/errors.md): every vault code, such as `InsufficientDeposit`, `AssetNotAllowed` and `InvalidInput`. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): the terms used here. ## What this does not do - **It does not set money aside.** A mandate is a limit, not a balance. The vault's own USDC pays every mandate, and the console lets the owner create a mandate whose per-payment cap is above what the vault holds, with a warning. A payout the vault cannot cover fails, and the vault releases it. - **It does not read the purpose document.** The contract stores the hash and checks only its format. Matching an invoice to its purchase order happens off chain, in the SDK. - **It is not a cap for the whole vault.** Each mandate has its own limits. The cap across all mandates is the vault's daily backstop, `OverDailyBackstop`. - **It does not check the agent key or the threshold when it is created.** See [What the contract checks](#what-the-contract-checks). - **Its status does not show expiry.** Read `expires_at_ns`. - **It cannot be edited or restored.** A revoked mandate stays revoked. - **It has barely run on mainnet.** One mandate exists there: mandate 1 of `v0.probarion.near`, which paid 1 USDC on NEAR on 2026-11-11. No mandate to a Base payee exists on mainnet. --- # How a payment is checked > The vault runs 14 checks in a fixed order on every spend; the first failure is the answer. Amount limits are inclusive. Dry-run one with check_spend. Every spend an agent sends meets the same 14 checks, in the same order, inside the vault contract. The first check that fails is the answer, and the call changes nothing. Status: Live · Network: Testnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/payment-checks When a check fails, the vault refuses the spend: the call panics with `PROBARION:`, which stops it and undoes every change it made, so the vault is left as it was. A spend that passes is reserved first. Then its payout starts in the same call, or it is held for an approver. The vault's free `check_spend` view runs the same checks and sends nothing, so you can learn the answer before you spend. ## The path of one spend 1. **Mandate** The owner signs the limits from their own wallet. 2. **Spend** The agent's key calls `spend` on the vault. 3. **14 checks** The first one that fails refuses the spend. 4. **Hold or pay** Above `approval_above`, an approver decides. Otherwise the vault starts the payout at once. 5. **Ledger** The SDK signs the decision before the spend and the outcome after it, and anchors its ledger on the vault. The contract's `spend` method is steps 2 to 4: `contracts/vault/src/spend.rs` ```rust /// Agent key only: the vault itself signs (predecessor == vault) with a registered agent key. pub fn spend(&mut self, request: SpendRequest) -> PromiseOrValue { if env::predecessor_account_id() != env::current_account_id() { fail(ErrorCode::NotAgent) } let signer_pk = env::signer_account_pk(); let now = env::block_timestamp(); let approved = match self.evaluate(&request, &signer_pk, now) { Ok(approved) => approved, Err(code) => fail(code), }; let spend_id = self.reserve(request, signer_pk, now, &approved); if approved.hold { return PromiseOrValue::Value(self.hold(spend_id)); } PromiseOrValue::Promise(self.execute_payout(spend_id)) } ``` Before any check, `spend` refuses a call that does not come from the vault account itself. An agent key is a function-call key on the vault account that may call only `spend`, so a spend signed with it comes from the vault. A call from any other account, the owner's included, gets [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent) at once. Then `evaluate()` runs the 14 checks, `reserve()` books the spend, and the vault holds it or starts the payout. ## What the agent can ask for The request names a mandate, an amount, the invoice, a ref and the decision behind it: `contracts/vault/src/types.rs` ```rust pub struct SpendRequest { pub mandate_id: u64, pub amount: U128, pub invoice_hash: String, pub r#ref: String, pub decision_hash: String, } ``` | Field | What the checks read | | - | - | | `mandate_id` | The mandate to spend under. Ids start at 1. An unknown id is `NoMandate`. The checks after it on revocation, expiry, the agent key, the per-payment cap, uses and the total read this mandate, and so does the hold. | | `amount` | A decimal string in USDC's smallest unit: `"5000000"` is 5 USDC. It is checked against zero, `max_per_payment`, `max_total` and the daily backstop, then compared with `approval_above` for the hold. | | `invoice_hash` | 64 lowercase hex: the sha256 of the invoice text, as the SDK's `hashInvoice()` computes it. It must not belong to a spend that is still held, pending, paid or burned (`DuplicateInvoice`). | | `ref` | 1 to 64 bytes, chosen by the caller. The vault accepts a ref once (`DuplicateRef`). | | `decision_hash` | 64 lowercase hex: the body hash of the decision entry in the ledger. The vault checks its shape, stores it with the spend and puts it in the `spend_submitted` event. | In the `invoice_hash` row, `pending` means the payout was sent and its callback has not come back yet, and [`burned`](https://probarion.xyz/docs/resources/glossary.md#burned) means a payout to a Base or Solana payee left the vault through NEAR Intents, which burns the token on NEAR for delivery on that chain. A burned spend was paid out, not lost. The signing key is not a field. `spend` reads it from the transaction, and `check_spend` takes it as an argument. > **There is no payee field** > > The agent says which mandate, how much and for which invoice. The vault reads the destination from the mandate the owner signed. A hijacked prompt has no field to put an address in. See [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). ## The 14 checks, in order `evaluate()` in `contracts/vault/src/checks.rs` runs these checks, and both `spend` and `check_spend` call it. When two checks would fail, the first one is the answer: a spend over the per-payment cap on a frozen vault gets `Frozen`. The contract's own tests pin the order, pair by pair. | # | The vault asks | Refused with | | - | - | - | | 1 | Is the signing key one of the vault's agent keys? | [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent) | | 2 | Are `invoice_hash` and `decision_hash` 64 lowercase hex characters, and is `ref` 1 to 64 bytes? | [`InvalidInput`](https://probarion.xyz/docs/build/errors.md#InvalidInput) | | 3 | Is the vault not frozen? | [`Frozen`](https://probarion.xyz/docs/build/errors.md#Frozen) | | 4 | Does the vault have a mandate with this id? | [`NoMandate`](https://probarion.xyz/docs/build/errors.md#NoMandate) | | 5 | Is the mandate not revoked? | [`MandateInactive`](https://probarion.xyz/docs/build/errors.md#MandateInactive) | | 6 | Is it still before the mandate's `expires_at_ns`? | [`MandateExpired`](https://probarion.xyz/docs/build/errors.md#MandateExpired) | | 7 | Is the signing key the agent key this mandate names? | [`NotMandateAgent`](https://probarion.xyz/docs/build/errors.md#NotMandateAgent) | | 8 | Is the amount more than zero? | [`ZeroAmount`](https://probarion.xyz/docs/build/errors.md#ZeroAmount) | | 9 | Is the amount at most the mandate's `max_per_payment`? | [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment) | | 10 | Has the mandate been used fewer than `max_uses` times? | [`UsesExhausted`](https://probarion.xyz/docs/build/errors.md#UsesExhausted) | | 11 | Do the mandate's spent and reserved amounts, plus this one, stay within `max_total`? | [`OverMandateTotal`](https://probarion.xyz/docs/build/errors.md#OverMandateTotal) | | 12 | Is this `invoice_hash` not already in use by another spend? | [`DuplicateInvoice`](https://probarion.xyz/docs/build/errors.md#DuplicateInvoice) | | 13 | Has the vault never accepted a spend with this `ref`? | [`DuplicateRef`](https://probarion.xyz/docs/build/errors.md#DuplicateRef) | | 14 | Does the vault's total for this UTC day, plus this amount, stay within the daily backstop? | [`OverDailyBackstop`](https://probarion.xyz/docs/build/errors.md#OverDailyBackstop) | `NotAgent` can come from either place: from `spend` itself, when the call does not come from the vault account, or from check 1, when the signing key is not in the vault's list of agent keys. The 14 are the checks of `evaluate()` only. ### The edges of each limit The amount limits are inclusive: a value equal to the limit passes. Expiry is not: a spend at exactly `expires_at_ns` is refused. | Check | Refuses when | Exactly at the limit | | - | - | - | | `MandateExpired` | The block time is at or after `expires_at_ns`. | Refused. The mandate is valid until the instant it expires. | | `OverPerPayment` | The amount is above `max_per_payment`. | Passes. | | `UsesExhausted` | `uses` has reached `max_uses`. | The last use passes. | | `OverMandateTotal` | `spent` plus `reserved` plus the amount is above `max_total`. | Passes. | | `OverDailyBackstop` | The vault's total for the day plus the amount is above `daily_backstop`. | Passes. | The hold rule, which is not a check, is strict as well: only an amount above `approval_above` is held. ### What the counters count - `uses` and `reserved` grow when the vault accepts a spend, before any payout. Held and in-flight spends count against `max_uses` and `max_total` before they settle. A failed, rejected or expired spend gives its use and its amount back. - The daily backstop is one counter for the whole vault, across all its mandates, for the UTC day of the block (`block_timestamp / DAY_NS`). Held, pending, paid and burned spends of that day count. It starts again at UTC midnight, and a spend released on the day it was counted gives its share back. On `v0.probarion.testnet` the backstop is 500 test USDC. - An `invoice_hash` is taken while a spend on it is held, pending, paid or burned. A failed, rejected or expired spend frees it for a retry. - A `ref` is taken once the vault accepts a spend with it, whether the spend is held or paid at once. It stays taken even if the payout fails or the hold is rejected or expires. A refused spend never takes its ref. ### The source The checks as the contract runs them: `contracts/vault/src/checks.rs` ```rust /// Preconditions: the signer is an agent (`NotAgent`) and the request is well formed /// (`InvalidInput`). Then, in order: frozen, mandate exists, active, not expired, agent /// matches, amount > 0, <= per-payment, uses left, spent + reserved + amount <= total, /// invoice unused, ref unused, daily backstop. pub fn evaluate( &self, req: &SpendRequest, signer_pk: &PublicKey, now: u64, ) -> Result { if !self.is_agent(signer_pk) { return Err(ErrorCode::NotAgent); } if !is_hex64(&req.invoice_hash) || !is_hex64(&req.decision_hash) || !is_ref(&req.r#ref) { return Err(ErrorCode::InvalidInput); } if self.frozen { return Err(ErrorCode::Frozen); } let mandate_idx = Self::position(req.mandate_id).ok_or(ErrorCode::NoMandate)?; let m = self.mandates.get(mandate_idx).ok_or(ErrorCode::NoMandate)?; if m.revoked { return Err(ErrorCode::MandateInactive); } if now >= m.expires_at_ns { return Err(ErrorCode::MandateExpired); } if m.agent_pk != *signer_pk { return Err(ErrorCode::NotMandateAgent); } let amount = req.amount.0; if amount == 0 { return Err(ErrorCode::ZeroAmount); } if amount > m.max_per_payment { return Err(ErrorCode::OverPerPayment); } if m.uses >= m.max_uses { return Err(ErrorCode::UsesExhausted); } if m.spent.saturating_add(m.reserved).saturating_add(amount) > m.max_total { return Err(ErrorCode::OverMandateTotal); } if self.invoices.contains_key(&req.invoice_hash) { return Err(ErrorCode::DuplicateInvoice); } if self.refs.contains(&req.r#ref) { return Err(ErrorCode::DuplicateRef); } let day = now / DAY_NS; if self.day_total_on(day).saturating_add(amount) > self.daily_backstop { return Err(ErrorCode::OverDailyBackstop); } Ok(Approved { mandate_idx, hold: amount > m.approval_above, day, }) } ``` ## What a refusal does Every refusal goes through one helper, and its message is exactly `PROBARION:`: `contracts/vault/src/errors.rs` ```rust /// Aborts the call with `PROBARION:`. All state changes of the call are reverted. pub fn fail(code: ErrorCode) -> ! { env::panic_str(&code.message()) } ``` On NEAR, a panic undoes every state change the call made. After a refused spend: - nothing is reserved, and the mandate's `uses`, `reserved` and `spent` are as they were; - the vault's total for the day is unchanged; - the vault has not recorded the `invoice_hash` or the `ref`, so both stay free; - no transfer was scheduled. The vault's property test, `contracts/vault/tests/invariants.rs`, runs random sequences of spends, approvals, rejections, expiries and callbacks. It asserts that `spend` refuses exactly when `check_spend` refuses, with the same code, and that a refused spend changes no state. The SDK reports a refusal as `refused_by_contract` with the bare code, such as `OverPerPayment`. Look each code up in [Errors](https://probarion.xyz/docs/build/errors.md). > **A refusal costs nothing but gas** > > The call is reverted, nothing is reserved, and the ref can be used again. Fix what the code names and send the spend again. An accepted spend is different. If its payout fails, if its hold is rejected, or if `expire_held` releases it after the hold expires, the vault gives back the amount, the use and the `invoice_hash`, but not the `ref`. A corrected retry needs a new ref. A hold whose time has passed gives nothing back by itself: it stays held, with its use, its reservation and its `invoice_hash`, until an approver rejects it or someone calls `expire_held`. `reject` has no time check, so an approver can still reject a hold after its time. That is inferred: it follows from the code, and no test pins it yet. Until the hold is released, a retry of the same invoice still gets `DuplicateInvoice`. ## Paid now, or held for a person A spend that passes the 14 checks is reserved before anything is sent. `reserve()` adds the amount to the mandate's `reserved` and to the vault's total for the day, adds one to the mandate's `uses`, records the `invoice_hash` and the `ref`, and stores the spend under the next spend id. Then one comparison decides: | Amount | What happens | | - | - | | Above `approval_above` | Held. `spend` returns the status `held`, and nothing is paid out yet. The spend waits for an approver until its `held_until_ns`, 72 hours after the spend on `v0.probarion.testnet`. | | At or below `approval_above` | The payout starts in the same call. Its callback marks the spend `paid`, `burned` or `failed`. | A held spend needs one approver from the vault's list. That approver can approve it or reject it, and once the hold has expired anyone can call `expire_held` to release it. `approve` does not run the 14 checks again. It checks the approver and the 1 yoctoNEAR, that the vault is not frozen, that the spend is still held, that the hold has not expired and that the mandate is not revoked. Then it starts the payout. More on [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md). > **Approver signs** > > An approver signs `approve` or `reject` from their own wallet, with exactly 1 yoctoNEAR attached. > > _An approver signs this transaction. Probarion never signs it._ For a payout, the vault reads the payee from the mandate. A NEAR payee is paid with `ft_transfer` on the vault's USDC token. A Base or Solana payee is paid through NEAR Intents with `ft_withdraw` and the memo `WITHDRAW_TO:
`, which the vault builds from the mandate. On testnet, `intents-stub.probarion.testnet` stands in for `intents.near`, so the burn is recorded and nothing leaves NEAR. See [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). Whether the payout starts from `spend` or from `approve`, its callback marks the spend `paid`, `burned` or `failed`. A failed spend gives back the amount, the use and the `invoice_hash`, but not the ref. A payout to a NEAR payee fails, for example, when the vault holds less USDC than the amount: the 14 checks do not read its balance. > **One refusal can come after the checks** > > Before it sends a payout to a Base or Solana payee, the vault checks that the mandate's asset is still in its allowed assets, and refuses with [`AssetNotAllowed`](https://probarion.xyz/docs/build/errors.md#AssetNotAllowed) if not. This guard is not one of the 14, and `check_spend` does not run it. Like any refusal, it undoes the call that started the payout: `spend`, or `approve` for a held spend. `create_mandate` already refuses an asset outside the list, and no vault method changes the list after the vault is created. So this guard should never fire on a vault today. That is inferred: it follows from the code, and no test shows it yet. ## Try a spend without sending it `check_spend` is a view method: it costs nothing, needs no signature and changes no state. It runs the same `evaluate()` for the key you pass, at the current block's time: `contracts/vault/src/checks.rs` ```rust /// Free dry run of `spend` for `signer_pk`. No state change. pub fn check_spend(&self, request: SpendRequest, signer_pk: PublicKey) -> Verdict { match self.evaluate(&request, &signer_pk, env::block_timestamp()) { Ok(approved) => Verdict { ok: true, code: None, would_hold: approved.hold, }, Err(code) => Verdict { ok: false, code: Some(code.as_str().to_string()), would_hold: false, }, } } ``` It returns `{ ok, code, would_hold }`. The `code` is the bare name, such as `OverPerPayment` with no `PROBARION:` prefix, and `would_hold` is false on a refusal. It judges the key it is given, not the caller. It does not run the rule of `spend` about the calling account, or the payout's `AssetNotAllowed` guard. The answer holds for the block it was asked in. A spend sent later can get another answer if the vault changed in between: another spend took the last use or the ref, the mandate expired, or a new UTC day began. In a clone built the way [Install](https://probarion.xyz/docs/build/install.md) shows, the CLI's `check` asks the vault for you. It hashes the invoice file the way the SDK does and passes a zero `decision_hash` to the view. With no `--agent-pk` and no `PROBARION_AGENT_KEY`, it checks as the agent key the mandate names. Nothing is signed or sent: ```bash printf 'Invoice TRY-1 for 6 test USDC\n' > try-invoice.txt node packages/cli/dist/bin.js --network testnet check --vault v0.probarion.testnet --mandate 1 \ --amount 6 --invoice try-invoice.txt --ref TRY-1 ``` Its first line is one of these, and the next lines name the key it checked as and the `invoice_hash`: ```text OK: the vault would accept this spend. OK: the vault would accept this spend and hold it for an approver. REFUSED: PROBARION: ``` It exits 0 when the vault would accept the spend, 1 when the vault would refuse it or the check could not run, and 64 for a usage error. This example asks for 6 test USDC on a mandate that allows at most 5, so its first line is `REFUSED: PROBARION:OverPerPayment` and it exits 1. If `PROBARION_AGENT_KEY` holds a key that is not one of this vault's agent keys, the answer is `NotAgent` instead, because the CLI checks as that key. Unset it to check as the agent key the mandate names. The README's evaluation asks the same vault through the SDK's `checkSpend()`. Its recorded answers on mandate 1 for 2 and 6 USDC: ```text 2 USDC: { ok: true, code: null, would_hold: false } 6 USDC: { ok: false, code: 'OverPerPayment', would_hold: false } ``` Mandate 1 was created by the vault's 2026-11-01 smoke run and expires 30 days later, on 2026-12-01, at the time of day of that run. After that, the CLI example and both SDK calls should answer `MandateExpired`, because expiry is checked before the per-payment cap. That is inferred: it follows from the code, and no test shows it on this vault yet. [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md) runs that step with no wallet. ## Code checks first, the contract decides last In the SDK, `process()` runs `decide()` from `@probarion/core` before anything reaches the vault. If code refuses, `process()` returns `refused_by_code` and never asks the vault. The built-in rules that mirror the vault's checks run in the contract's order, with the contract's comparisons, so the first mirrored rule that refuses names the code `spend` would panic with: `vault.frozen` for `Frozen`, `mandate.none` for `NoMandate`, `vault.daily_backstop` for `OverDailyBackstop`. `NotAgent` has no mirror, because `decide()` takes the agent key as given. `decide()` also runs rules the contract cannot check. The purchase order (`invoice.po_mismatch`) and a payee named on the invoice (`mandate.payee_mismatch`) run between the mirrored rules, after the agent key's rule and before the amount rule. The Intents balance (`intents.balance_low`, which holds) and the model's proposal, when one passes its attestation checks, run after the last mirrored rule, `vault.daily_backstop`. Then `process()` asks `check_spend`, with the agent's key, before it sends `spend`: | `check_spend` says | What `process()` does | | - | - | | Refused | Returns `refused_by_contract` with the bare code and `txHash` null. Nothing was sent. | | Would pay at once, but code held the payment | Returns `held` with `spendId` null. Nothing was sent. | | Accepted | Sends `spend`, signed with the agent key. | No call releases a code hold, the `held` with `spendId` null. Change what caused the hold, then run the cycle again. If a sent `spend` panics anyway because the vault changed after the dry run, for example with `Frozen`, `process()` returns `refused_by_contract` with the failed transaction's hash. A `DuplicateRef` or `DuplicateInvoice` panic is the exception. The SDK looks the spend up by ref in the vault's spend list: - If a spend of another decision holds the ref or the invoice, the outcome is `refused_by_contract` with the hash. - If the spend is this decision's own, because an earlier copy of the transaction landed and its answer was lost, its status decides: `paid`, `burned` or `held` as it stands, `unconfirmed` while it is pending, and `failed` once the vault released it. - If no spend holds it in the list read, or the lookup fails, the outcome is `unconfirmed`. It may have paid, so never send it again. The low-level `requestSpend()` asks `check_spend` too, unless it is called with `skipPreflight: true`. The contract is the final word. Even if every line of off-chain code were wrong, the vault refuses a spend outside its mandate. What to do with each result is on [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md), which lists the seven statuses `process()` returns. ## Where to go next - [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md): how the destination stays out of the agent's hands. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): every mandate field these checks read. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): what happens to a held spend. - [Ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md): how the SDK records each decision and outcome. - [Errors](https://probarion.xyz/docs/build/errors.md): every vault code, and what to do about it. - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle from your own code. - [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md): the whole path, from mandate to record. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): the words this page uses. ## What this does not do - The checks hold the agent to the mandate. They do not judge whether a payment inside it is wise. - The vault never sees the invoice, only its hash. It does not compare a spend with the purchase order: that rule runs off chain, in `decide()`. - The vault checks only the shape of `decision_hash`. Tying each spend to its decision is check d of the verifier, on [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md). Those checks a to i are a different list from these 14. - The checks do not read the vault's USDC balance. A spend the vault cannot cover passes them and is reserved, then its transfer fails: the callback marks it `failed` and gives back the amount, the use and the `invoice_hash`, but not the ref. See [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md). - `check_spend` reserves nothing. A pass now does not keep the use, the amount or the ref for a later spend. - `approve` does not run the 14 checks again. - The daily backstop is one limit for the whole vault, not one per mandate or per agent key. - On mainnet the checks have run on two spends only, both on `v0.probarion.near` on 2026-11-11: one paid, and one refused with `NoMandate`. --- # Holds and approvals > When a spend waits for a person, who may approve or reject it, when the hold expires, and how a hold by code differs from a hold on chain. A spend above its mandate's approval threshold waits in the vault until one of its approvers approves or rejects it, and once its time runs out anyone can release it. Code can also hold a payment before anything is sent, and no approver can act on that one. Status: Preview · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/holds-and-approvals The contract holds a spend whose amount is above its mandate's `approval_above`: the amount is reserved against the mandate, and the spend has an id that every call ending the hold names. A code hold has no spend id and puts no spend on chain. Nothing was sent, so the way out is to change what caused the hold, then run the cycle again. > **Preview** > > Holds and approvals are covered by the contract's unit tests and its sandbox tests; rejections and expiries by its unit tests. On NEAR testnet, the demo vault held spends 2 and 3 on 2026-11-09, and its approver approved both. On 2026-11-12 the owner of `trial.vaults.probarion.testnet`, who is also on its approver list, rejected its held spend 4 on the console's approval page with a real wallet (Meteor), and nothing was paid ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md#2026-11-12-reject-revoke-a-spare-agent-key-and-withdraw-all)). No expiry has been recorded there yet. ## Two kinds of hold The SDK reports both as `held`. The spend id tells them apart. | What | Contract hold | Code hold | | - | - | - | | Why | The amount is above the mandate's `approval_above`. | A code rule returned `hold`, and `check_spend` says the contract would pay at once. | | `process()` and `requestSpend()` return | `held` with a `spendId` | `held` with `spendId` null, and `code` naming the rule | | On chain | A spend with status `held` and a `held_until_ns` | No spend: only the cycle's ledger anchor | | Reserved | The amount, one use, a share of the day's backstop, the invoice hash and the ref | Nothing | | Who can end it | An approver (approve or reject), or anyone once its time has passed | Nobody on chain: you change the cause and run the cycle again | | In Payables, the console's reference agent that pays invoices | "Held for approval" | "Held by code: nothing was sent". When Payables' own owner-confirms rule held it, the owner reads "Waiting for you: no attested model confirmed the amount", and anyone else reads "Waiting for the owner" in place of "Waiting for you" | ## When the contract holds a spend The hold is the last step of the vault's check. A spend first passes all 14 checks on [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md). Then, if its amount is above the mandate's `approval_above`, the vault holds it. The rule is strict: an amount equal to `approval_above` pays at once. A mandate whose `approval_above` is at or above its `max_per_payment` never holds a spend, and the CLI's `mandate create` says so when you print one. Every limit is on [Mandates](https://probarion.xyz/docs/concepts/mandates.md). A held spend is reserved exactly like a spend that pays at once. The vault adds the amount to the mandate's `reserved`, counts one use, adds the amount to the day's backstop total, and marks the invoice hash and the ref as used. Then it stores the spend with status `held` and sends nothing. `spend` answers at once with `{"spend_id":N,"status":"held"}`, and logs a `spend_held` event with `spend_id`, `mandate_id`, `amount`, `invoice_hash`, `decision_hash` and `held_until_ns`. `held_until_ns` is the block time at which the vault accepted the spend plus the vault's `held_ttl_ns`. That hold time is one value for the whole vault, not per mandate. It is set when the vault is created, must be above 0 and at most 30 days, and no vault method changes it later. The CLI's `vault create` and the console's create form both default to 72 hours and take whole hours from 1 to 720. `v0.probarion.testnet` uses 72 hours. A held spend counts against its mandate while it waits. If it takes the mandate's last use, the mandate reads `exhausted` until a release gives the use back. To learn before sending whether the vault would hold a spend, dry-run it with the free `check_spend` view. The CLI commands on this page run from a clone built as [Install](https://probarion.xyz/docs/build/install.md) shows, and the `near` commands are near-cli-rs: ```bash # Illustrative values: mandate 1 of this vault holds any spend above 2 USDC. node packages/cli/dist/bin.js --network testnet check --vault acme.vaults.probarion.testnet \ --mandate 1 --amount 3 --invoice ../invoice.txt --ref INV-3 ``` The first line of the answer says the vault would hold it: ```text OK: the vault would accept this spend and hold it for an approver. ``` A dry run changes nothing, so it uses up no ref. ## Approve, reject or let it expire 1. **Held** The spend passed the 14 checks and is reserved. Nothing is paid out yet. 2. **Decided** An approver approves or rejects it, or, once its time has passed, anyone expires it. 3. **Closed** An approval pays out: `paid` or `burned`, or `failed` and released. A rejection or an expiry releases it as `rejected` or `expired`. | Call | Who | Deposit | Allowed when | Result | | - | - | - | - | - | | `approve(spend_id)` | One of the vault's approvers | Exactly 1 yoctoNEAR | The spend is `held`, the block time is at or before `held_until_ns`, the vault is not frozen, and the mandate is not revoked | `spend_approved`, then the payout. The spend turns `pending`, then `paid`, `burned` or `failed`. | | `reject(spend_id)` | One of the vault's approvers | Exactly 1 yoctoNEAR | The spend is `held`. Also while the vault is frozen, after the mandate is revoked, and after the hold has run out | Released as `rejected`, with a `spend_rejected` event | | `expire_held(spend_id)` | Anyone | None | The spend is `held` and the block time is past `held_until_ns` | Released as `expired`, with a `spend_expired` event | One approver is enough: there is no quorum. The approver list is set when the vault is created, 1 to 10 accounts, and no vault method changes it. The owner is not an approver unless the vault was created with the owner on that list. When the owner is the only approver, the console's create form warns: "You are the only approver, so a held payment needs nobody else's signature. A second person is safer." `approve` and `expire_held` never overlap. Up to and including `held_until_ns`, `approve` works and `expire_held` is refused with [`HoldNotExpired`](https://probarion.xyz/docs/build/errors.md#HoldNotExpired). Once the block time is past it, `approve` is refused with [`HoldExpired`](https://probarion.xyz/docs/build/errors.md#HoldExpired) and `expire_held` works. `reject` has no time check at all. A hold does not end by itself. Past `held_until_ns` the spend still reads `held` on chain and keeps its reservation until an approver rejects it or someone calls `expire_held`. Nothing in the app calls `expire_held`. ### Find the held spend The spend id comes back as the outcome's `spendId` and in the `spend_held` event. On the vault's page in the console, the "Held for approval" card lists each held spend as Spend #N, with its mandate, amount, payee, ref and the time its hold ends. Without the console, read the vault's spend list with `list_spends` and look for the ref: `get_spend` takes a spend id, not a ref. Both are free view calls. `list_spends` returns at most 100 spends a call, starting at the 0-based `from_index`, so the next page starts at `from_index` 100. `get_spend` shows one spend's `status` and `held_until_ns`: ```bash near contract call-function as-read-only acme.vaults.probarion.testnet list_spends json-args '{"from_index":0,"limit":100}' network-config testnet now near contract call-function as-read-only acme.vaults.probarion.testnet get_spend json-args '{"spend_id":3}' network-config testnet now ``` ### Sign approve or reject In the console, an approver sees Reject on each held spend, and Approve unless the hold has run out or the vault is frozen. Anyone else who can open the vault's page, the owner too unless the owner is on the approver list, reads "Only the vault's approvers can approve or reject." Each held spend also has a page of its own, sized for a phone: `/console//approve/`. The "Approval needed" alert, by Telegram and by email, links to it, and so does the held card on the vault's overview. The page reads the spend, its mandate and the vault's config from the chain and shows the amount, the payee, the mandate, why the spend is held and when the hold ends. It offers Approve and Reject only to an account on the vault's approver list on chain, and only while the chain reads the spend as held; Approve also waits for an unfrozen vault, an open hold and a mandate that is not revoked. The approver signs in their own wallet. On a phone that is a mobile wallet: Meteor Wallet on testnet, HOT Wallet on mainnet, where NEAR Connect offers it. On 2026-11-11 the owner of a testnet vault approved two held spends on this page with a real wallet (Meteor), and each paid; that run is not confirmed on a phone, since its screenshots are from a desktop browser. On 2026-11-12 the same owner rejected a third held spend on this page, and nothing was paid: the page then read "Rejected", and the mandate got its use and its amount back. That run was in a desktop browser too. The app signs nothing. The page names the console's invoice for the spend only when the console sent the spend from it. The invoice hash and the ref are the agent's to choose, so an invoice that only has the same hash and ref is shown with a warning, beside its own amount. With the CLI, the approver prints the transaction and signs it in their own wallet: ```bash # Illustrative values: your vault, the held spend's id and your approver account. node packages/cli/dist/bin.js --network testnet spend approve --vault acme.vaults.probarion.testnet \ --spend 3 --sign-as cfo.testnet --out-dir probarion-tx node packages/cli/dist/bin.js --network testnet spend reject --vault acme.vaults.probarion.testnet \ --spend 3 --sign-as cfo.testnet --out-dir probarion-tx ``` `spend approve` prints `approve` with 150 Tgas, because the payout and its callback run inside it. `spend reject` prints `reject` with 30 Tgas. Illustrative, shortened: ```text APPROVER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: near contract call-function as-transaction acme.vaults.probarion.testnet approve file-args ./probarion-tx/approve.<12 hex>.args.json prepaid-gas '150.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as cfo.testnet network-config testnet sign-with-keychain send approve arguments (./probarion-tx/approve.<12 hex>.args.json): { "spend_id": 3 } This executes the payout to the mandate's payee. It is valid until the hold expires and refused while the vault is frozen. ``` > **Approver signs** > > An approver signs `approve` or `reject` from their own wallet, with exactly 1 yoctoNEAR attached. The CLI prints the transaction and never signs it. > > _An approver signs this transaction. Probarion never signs it._ ### Release an expired hold The CLI and the console have no command for `expire_held`. Any account can send it with near-cli-rs, with no deposit: ```bash # Illustrative: any account may sign. 30 Tgas, as for reject, which runs the same release. near contract call-function as-transaction acme.vaults.probarion.testnet expire_held \ json-args '{"spend_id":3}' prepaid-gas '30 Tgas' attached-deposit '0 NEAR' \ sign-as network-config testnet sign-with-keychain send ``` ## What approve checks, and what it does not `approve` checks, in this order, and the first failure is the answer: 1. The caller is one of the vault's approvers, else [`NotApprover`](https://probarion.xyz/docs/build/errors.md#NotApprover). A stranger who attaches no deposit gets `NotApprover`, not [`RequiresOneYocto`](https://probarion.xyz/docs/build/errors.md#RequiresOneYocto). 2. Exactly 1 yoctoNEAR is attached, else `RequiresOneYocto`. 3. The vault is not frozen, else [`Frozen`](https://probarion.xyz/docs/build/errors.md#Frozen). 4. The spend exists and is `held`, else [`NotHeld`](https://probarion.xyz/docs/build/errors.md#NotHeld). 5. The block time is at or before `held_until_ns`, else `HoldExpired`. 6. The mandate exists and is not revoked, else [`MandateInactive`](https://probarion.xyz/docs/build/errors.md#MandateInactive). Then it logs `spend_approved` with the spend id and the approver, and sends the payout to the mandate's payee. The reservation stays until the payout's callback settles the spend or releases it. A second `approve`, by the same approver or another, gets `NotHeld`: the spend is no longer `held`, so it cannot be paid twice. Sending the payout can refuse too. For a Base or Solana payee, the vault first checks that the mandate's asset is still in its allowed assets, and refuses with [`AssetNotAllowed`](https://probarion.xyz/docs/build/errors.md#AssetNotAllowed) if not. Like any refusal, that undoes the whole `approve`. `create_mandate` already refuses an asset outside the list, and no vault method changes the list after the vault is created, so this should never fire. That is inferred: it follows from the code, and no test shows it yet. `contracts/vault/src/held.rs` ```rust #[payable] pub fn approve(&mut self, spend_id: u64) -> PromiseOrValue { self.assert_approver(); require_one_yocto(); if self.frozen { fail(ErrorCode::Frozen) } let spend = self.held_spend(spend_id); if env::block_timestamp() > spend.held_until_ns.unwrap_or(0) { fail(ErrorCode::HoldExpired) } if self.mandate(spend.mandate_id).is_none_or(|m| m.revoked) { fail(ErrorCode::MandateInactive) } VaultEvent::SpendApproved { spend_id, by: env::predecessor_account_id(), } .emit(); PromiseOrValue::Promise(self.execute_payout(spend_id)) } #[payable] pub fn reject(&mut self, spend_id: u64) { self.assert_approver(); require_one_yocto(); self.held_spend(spend_id); self.release(spend_id, SpendStatus::Rejected); VaultEvent::SpendRejected { spend_id, by: env::predecessor_account_id(), } .emit(); } pub fn expire_held(&mut self, spend_id: u64) { let spend = self.held_spend(spend_id); if env::block_timestamp() <= spend.held_until_ns.unwrap_or(u64::MAX) { fail(ErrorCode::HoldNotExpired) } self.release(spend_id, SpendStatus::Expired); VaultEvent::SpendExpired { spend_id }.emit(); } ``` `approve` does not run the 14 checks again. The mandate's expiry, its per-payment cap, its uses, its total and the daily backstop were checked when the spend was reserved, and the reservation already counts them. So a spend held before its mandate expired can still be approved after the expiry, while the hold is alive and the mandate is not revoked. This follows from the contract's code; no test shows it yet. To stop such a payment, the owner revokes the mandate: the console offers Revoke on an expired mandate that still has funds reserved, with the note "Expired, but part of it is still reserved by held or pending payments. Revoking stops any held payments from being approved." When the reserved figure cannot be read, it offers Revoke too, with a note that ends the same way. `approve` does not check the balance the payout draws on either: the vault's USDC on NEAR for a NEAR payee, or its balance inside intents for a Base or Solana payee. If that balance is too low when an approver approves, the payout fails, and the spend becomes `failed` and is released. ## What a release gives back A release happens when an approver rejects a held spend, when someone expires it, or when a payout fails. The vault reads the spend it reserved and gives part of it back: | Reserved at spend | After a release | | - | - | | The amount, in the mandate's `reserved` | Given back | | One of the mandate's uses | Given back | | The invoice hash | Given back: the same invoice can be paid again | | The amount's share of the day's backstop | Given back only on the same UTC day the spend was counted in | | The ref | Never given back | A hold that waits 72 hours always expires on a later UTC day, so its share stays on the day it was counted in. That share no longer limits anything: the backstop counts only the current day, and a new day starts from zero. The ref stays used for good. A ref is used up once the contract accepts a spend, held or paid, and it stays used even if the payout fails or the hold is rejected or expires. A corrected retry sends the same invoice under a new ref; the old ref gets [`DuplicateRef`](https://probarion.xyz/docs/build/errors.md#DuplicateRef). A spend the vault refuses reverts every change, so its ref stays free. `contracts/vault/src/spend.rs` ```rust /// Gives back the reservation, the use and the invoice_hash, so a corrected retry is possible. /// It also gives back the backstop share, but only when the release happens in the UTC day the /// reservation was counted in. The ref stays consumed forever: it is an idempotency key. /// Only a Held or Pending spend holds a reservation, so only those can be released. pub(crate) fn release(&mut self, spend_id: u64, status: SpendStatus) -> Spend { let spend = self .spend_record(spend_id) .cloned() .unwrap_or_else(|| fail(ErrorCode::InvalidInput)); if !matches!(spend.status, SpendStatus::Held | SpendStatus::Pending) { fail(ErrorCode::InvalidInput) } let mandate = self.mandate_mut(spend.mandate_id); mandate.reserved -= spend.amount; mandate.uses -= 1; let today = env::block_timestamp() / DAY_NS; if spend.day == today && spend.day == self.day_bucket { self.day_total = self.day_total.saturating_sub(spend.amount); } self.invoices.remove(&spend.invoice_hash); self.set_status(spend_id, status); spend } ``` The spend statuses, as `get_spend` and `list_spends` show them: | Status | What it means | What can follow | | - | - | - | | `held` | Reserved and waiting for an approver. Nothing is paid out yet. | `pending` through `approve`, `rejected`, or `expired` | | `pending` | The payout was sent and its callback has not settled it yet. | `paid`, `burned` or `failed` | | `paid` | The NEAR payee was paid. | Nothing: final | | `burned` | The withdrawal for a Base or Solana payee ran on NEAR. | Nothing: final | | `failed` | The payout failed and the spend was released. | Nothing: final | | `rejected` | An approver rejected it and the spend was released. | Nothing: final | | `expired` | `expire_held` released it after its time. | Nothing: final | ## Code holds: nothing was sent Before it sends anything, the SDK decides with `decide()` from `@probarion/core`. When no rule refuses and at least one rule holds, the verdict is `hold`. The SDK then asks `check_spend`. If the contract would refuse, the outcome is `refused_by_contract`. If the contract would accept and pay at once, the SDK holds the payment itself and sends nothing: `packages/sdk/src/probarion.ts` ```ts if (d.verdict === "hold" && !verdict.would_hold) { // The code holds but the contract would pay at once: hold off-chain, send nothing. const code = firstRule(d.rules, "hold"); return finish({ status: "held", spendId: null, code, txHash: null }, null); } ``` The rules that can hold: | Rule | Holds when | | - | - | | `intents.balance_low` | The payee is a Base or Solana address paid through NEAR Intents, and the vault's balance of that asset inside intents is unknown or below the amount. | | `model.refused` | A model proposal whose attestation checks passed asked to hold. | | `model.low_confidence` | That trusted proposal's confidence is below `confidenceFloor`, 0.7 by default. | | `custom.*` | One of your own rules returned `hold`. Payables adds `custom.owner_confirms_without_model` to every cycle: it holds when no attested model confirmed the amount the code read, and refuses instead when the contract would hold that amount itself. | The cycle still writes its decision entry, with the verdict `hold`. No payment entry follows, and no spend transaction reaches the vault: the only transaction is the usual ledger anchor, signed by the ledger key, when the SDK has one. The outcome is `held` with `spendId` null, `txHash` null, and `code` set to the first rule that held, for example `intents.balance_low`. No spend is on chain, so no approver can act on a code hold and no call releases it. Change what caused the hold, then run the cycle again. For `intents.balance_low`, the owner tops up the vault's balance inside intents. In Payables, an invoice its owner-confirms rule held shows a "Waiting for you" card, where the owner confirms the amount. No spend reached the vault, so the invoice and the ref are still free for the new cycle. If code holds and the contract would hold the spend too, the SDK sends it and the contract holds it: the outcome is `held` with a `spendId`, and an approver decides. This follows from the SDK's code; no test shows it yet. ## Revoke, freeze and key removal while a spend is held | While a spend is held, the owner | `approve` | `reject` | `expire_held` | | - | - | - | - | | Revokes its mandate | Refused with `MandateInactive` | Works | Works | | Freezes the vault | Refused with `Frozen` | Works | Works | | Removes the agent key that sent it | Works | Works | Works | - **Revoke.** The held spend stays `held` until an approver rejects it or, after its time, anyone expires it. Expiring a hold on a revoked mandate follows from the contract's code; no test shows it yet. An owner who is not an approver cannot reject a held spend, so revoking its mandate is how the owner stops it from being paid. Revoking also ends the mandate for every later spend, and it cannot be undone. - **Freeze.** Every new spend and every `approve` is refused until `unfreeze`. Freezing does not stop a hold's clock: `held_until_ns` stays as it was, so a hold can run out while the vault is frozen. After `unfreeze`, an approver can approve only a hold that is still alive. - **Key removal.** The vault deletes the agent's key, so it can sign no further spends. Spends already held still wait for an approver, because `approve` does not look at the agent key. This follows from the contract's code; no test shows it yet. > **Owner signs** > > The owner signs `revoke_mandate`, `freeze`, `unfreeze` and `remove_agent` from their own wallet. `freeze` takes no deposit, so any key on the owner's account that may call the vault can freeze it. The other three need exactly 1 yoctoNEAR. > > _The owner signs this transaction. Probarion never signs it._ ## Where to go next - [Install](https://probarion.xyz/docs/build/install.md): build the CLI from a clone, to run the commands on this page. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): `approval_above` and the other limits a held spend counts against. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks a spend passes before it can be held. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md): the owner, the approvers and the vault's keys. - [The agent key cannot approve itself](https://probarion.xyz/docs/trust/self-approval.md): why the agent key cannot approve its own held spend, and a test that tries. - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): what to do with each of the seven statuses `process()` returns, both kinds of `held` included. - [Errors](https://probarion.xyz/docs/build/errors.md): every vault code, with `HoldExpired`, `HoldNotExpired`, `NotHeld` and `NotApprover` among them. - [What works today](https://probarion.xyz/docs/start/what-works-today.md): what is Live, Preview and Planned. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): the terms on this page. ## What this does not do - A hold does not wait for several approvers. One listed approver's `approve` sends the payout, and one approver's `reject` releases the spend for good. - The owner cannot approve or reject unless the vault was created with the owner as an approver. The approver list and the hold time are fixed when the vault is created. - A hold does not end by itself. Until an approver rejects it or someone calls `expire_held`, a hold past its time stays `held` and keeps its reservation. - `approve` does not re-run the 14 checks. By the contract's code, a spend held before its mandate expired can still be paid unless the owner revokes the mandate; no test shows it yet. - A hold reserves the amount against the mandate, not the USDC itself. `withdraw` and `withdraw_intents` do not look at reservations, so the owner can take out USDC a held spend needs; an approved payout then fails and is released. - No approver can approve or release a code hold. Nothing was sent, so there is no spend on chain to act on. - On NEAR testnet, holds and their approvals have been recorded, on `demo.vaults.probarion.testnet` on 2026-11-09 (approved with near-cli) and on `trial.vaults.probarion.testnet` on 2026-11-11 (two holds approved on the console's approval page). One rejection has been recorded, on `trial.vaults.probarion.testnet` on 2026-11-12, by the owner, who is also on its approver list, on the console's approval page, and no expiry yet. Unit tests cover all four, and sandbox tests cover a hold and its approval, so this page is Preview. --- # The ledger and anchors > How each decision is signed into a hash-chained ledger before the spend is sent, and how anchors pin it on chain so a rewritten anchored range is caught. The vault records what was paid; the ledger records why. Each decision is signed and chained before the spend is sent, and anchors on the vault pin the ledger so a rewritten anchored range no longer matches the chain. Status: Preview · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/concepts/ledger-and-anchors The vault records what was paid. The ledger records why: the SDK signs each decision into a hash-chained ledger before it sends the spend, refusals included, and the spend on chain carries the hash of that decision. After every cycle the SDK anchors the ledger on the vault, so a rewrite of an anchored range no longer matches the chain. > **Key idea** > > The decision is signed into the ledger before the spend is sent. 1. **The decision is signed** The SDK appends a decision entry, signed with the vault's ledger key. 2. **The spend carries its hash** The decision entry's `body_hash` goes to the vault as `decision_hash`, and the vault stores it with the spend. 3. **The outcome is signed** A payment entry records the contract's answer and points back to the decision. 4. **An anchor pins the ledger** `anchor(seq, root, count)` stores the link of the last entry and the number of entries on the vault. The ledger code ships in `@probarion/core` and `@probarion/sdk`, on npm at 0.1.1, and its tests run on a committed test ledger. The full cycle with a ledger has run on the testnet demo vault, through the console's Payables: after the owner's Confirm, signed in their wallet, on 2026-11-09, and with no Confirm on 2026-11-12, when it wrote decision entry 13, sent spend 4, wrote payment entry 14 and anchored all 15 entries, and a live `verify` passed checks a to i ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model)). Later that day it did the same with entries 15 and 16 and spend 5, and anchored all 17 entries ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). A ledger kept by an agent of your own with the SDK is not recorded yet, so this page is Preview. The verifier, the CLI's `verify`, judges the ledger with [checks a to d](https://probarion.xyz/docs/trust/verifier-checks.md): check a reads the signatures, b the links, c the anchors, and d ties each spend on chain to its own decision. > **Note** > > `v0.probarion.testnet` has no ledger key and no anchors. Its smoke run on 2026-11-01 sent spends with the agent key directly, without the SDK or a ledger, so `verify --vault v0.probarion.testnet` prints `RESULT: FAIL` today: [check d](https://probarion.xyz/docs/trust/verifier-checks.md#check-d) finds spends with no decision in the ledger, and [check h](https://probarion.xyz/docs/trust/verifier-checks.md#check-h) finds no burn event without the burn's tx hash. ## Why a ledger The vault keeps a record of each spend: the mandate, the amount, the invoice hash, the ref, the decision hash, the agent key and the status. It does not keep the reasons. The ledger does: which rules ran and what each one said, the verdict, whether code decided alone or with a model, and the model's proposal and attestation evidence when there was one. Two properties make that record worth checking: - **It comes first.** The SDK writes the decision entry before it calls `check_spend` or sends `spend`, and the spend carries the hash of that entry. A decision changed afterwards fails the verifier. Left with its old hash and signature, it fails checks a and b at that entry, and check d too if its invoice hash, mandate, amount or verdict no longer fits the spend. Hashed and signed again, it has another hash, so the spend's `decision_hash` names no decision in the ledger and check d fails. - **It is chained and anchored.** Each entry is linked to the one before it, and an anchor on the vault holds the link of the last entry in its range. An edit inside an anchored range, or a cut below an anchored count, fails the verifier. Refusals go in too. A cycle that code refused sends nothing to the vault, so its decision entry is the only record of it. The whole cycle, from mandate to record, is on [How one payment works](https://probarion.xyz/docs/start/how-one-payment-works.md). ## What goes in the ledger An entry's `kind` is `decision`, `payment`, `approval`, `anchor` or `note`. The SDK writes only decision and payment entries: - **decision**: one for every cycle of `process()` or `requestSpend()`, whatever the verdict, written before anything is sent. - **payment**: written after `check_spend` refused or after `spend` was sent. It records the contract's answer and points back to its decision. A cycle that code refused, or a [code hold](https://probarion.xyz/docs/concepts/holds-and-approvals.md#code-holds-nothing-was-sent) (`held` with `spendId` null: nothing was sent), ends with the decision entry alone. Neither the SDK nor the web app writes `approval`, `anchor` or `note` entries today. Approvals happen on chain, and check d reads from the spend's own record whether it was held. The anchor's transaction hash comes back in the outcome as `anchorTxHash` and is never stored in an entry. ### The body of an entry | Field | Meaning | | - | - | | `v` | The ledger format version, `1`. Check b fails an entry with a version it does not know. | | `vault` | The vault account id. Check b fails an entry that names another vault. | | `seq` | The entry's position: 0 for the first entry, then up by one with no gaps. | | `prev_link` | The `link` of the entry before. 64 zeros at seq 0, and only there. | | `kind` | `decision`, `payment`, `approval`, `anchor` or `note`. | | `at` | On a decision, the time the cycle started, which the policy decided with. On a payment, when it was written. ISO-8601 in UTC. | | `request_id` | A fresh id for each cycle, or the `requestId` option on every entry the instance writes. A payment entry repeats the id of its decision. | | `mandate_id` | The mandate the cycle chose, or null when none fits. | | `invoice_hash` | The sha256 of the invoice text, as 64 lowercase hex. The text itself is not stored. | | `amount` | A decimal string in the token's smallest unit, or null. | | `proposal` | The model's proposal, or null when code decided alone. | | `evidence` | The model's attestation evidence, or null. | | `rules` | Each rule that ran: its id, its outcome (`pass`, `hold` or `refuse`) and a detail. | | `verdict` | `allow`, `hold` or `refuse` on a decision. Null on other kinds. | | `decided_by` | `model+rules` or `rules-only` on a decision. Null on other kinds. | | `contract` | On a payment entry, the contract's answer: `code`, `tx_hash`, `spend_id` and `status`. Null on a decision. | | `settlement` | On the payment entry of a burned spend to a Base or Solana payee: `chain`, `tx_hash` and `burn_tx_hash`. Null otherwise. | | `refs` | On a payment entry, `decision_body_hash`: the body hash of its decision. Null on a decision. The type also allows `anchor_seq`, which neither the SDK nor the web app writes. | The body has no `ref` field and stores no copy of the invoice text. The model's proposal and evidence can still quote it: the proposal's reasoning, the PO reference in `request_inputs`, which is a verbatim part of the invoice, and the model's response body, up to 64 KiB. When a model advises, its attestation is bound by a nonce to the decision entry about to be written: the nonce covers the vault, the `prev_link`, the `seq`, the invoice hash and the mandate id. That evidence fits one position in one vault's ledger and no other. ### An example decision entry Illustrative: a rules-only decision to pay 5 USDC under mandate 1, with the rules list cut short and placeholders for the hashes and keys. ```json { "body": { "v": 1, "vault": "acme.vaults.probarion.testnet", "seq": 2, "prev_link": "", "kind": "decision", "at": "2026-11-08T10:00:00.000Z", "request_id": "", "mandate_id": 1, "invoice_hash": "", "amount": "5000000", "proposal": null, "evidence": null, "rules": [ { "id": "vault.frozen", "outcome": "pass", "detail": "vault is not frozen" }, { "id": "mandate.none", "outcome": "pass", "detail": "mandate 1 selected by purpose_hash" }, { "id": "mandate.over_per_payment", "outcome": "pass", "detail": "amount 5000000 vs per-payment cap 5000000" } ], "verdict": "allow", "decided_by": "rules-only", "contract": null, "settlement": null, "refs": null }, "body_hash": "", "sig": "ed25519:...", "signer": "ed25519:...", "link": "<64 lowercase hex>" } ``` ## How an entry is sealed Three values seal the body, in this order: 1. `body_hash`: the sha256 of the RFC 8785 canonical JSON of the whole body. Canonical JSON sorts the keys, so the hash covers every field and does not depend on the order in which a file writes them. 2. `sig`: an ed25519 signature over the 32 raw bytes of `body_hash`, by the vault's ledger key. `signer` names that key. 3. `link`: `sha256(prev_link ‖ body_hash ‖ sig)`, with the raw 64-byte signature. Each entry's `prev_link` is the `link` of the entry before it, so changing one entry changes every link after it. `packages/core/src/ledger.ts` ```ts /** sha256 of the RFC 8785 canonical JSON of the whole body: every body field is covered. */ export function computeBodyHash(body: LedgerEntryBody): Hex32 { return sha256Hex(canonicalJson(body)); } /** link = sha256(bytes(prev_link) || bytes(body_hash) || raw 64-byte signature) (interfaces §6). */ export function computeLink(prevLink: Hex32, bodyHash: Hex32, sig: string): Hex32 { if (!isHex32(prevLink)) throw new LedgerFormatError("prev_link must be 64 lowercase hex"); if (!isHex32(bodyHash)) throw new LedgerFormatError("body_hash must be 64 lowercase hex"); let rawSig: Uint8Array; try { rawSig = parseSignature(sig); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new LedgerFormatError(`sig is malformed: ${reason}`, { cause: error }); } return bytesToHex(sha256Bytes(concatBytes(hexToBytes(prevLink), hexToBytes(bodyHash), rawSig))); } ``` An entry is the body plus those values: `packages/core/src/types.ts` ```ts export interface LedgerEntry { body: LedgerEntryBody; body_hash: Hex32; // sha256(utf8(canonicalize(body))) sig: string; // "ed25519:" over the 32 raw bytes of body_hash signer: string; // "ed25519:" = vault's ledger FC key link: Hex32; // sha256(bytes(prev_link) || bytes(body_hash) || raw 64-byte sig) } ``` `buildEntry` checks the signature before it returns an entry, so a broken signer is caught before anything reaches a store. It also refuses a `prev_link` of 64 zeros on any entry but the first. `signer` is covered by no hash and no signature. So the verifier's check a compares it with the key the chain names: for each anchored range, the ledger key stored with that anchor, and after the last anchor, the vault's current ledger key. ## How a spend points to its decision The SDK appends the decision entry and sends its body hash as `decision_hash`: `packages/sdk/src/probarion.ts` ```ts const decisionEntry = await this.appendDecision(c); const decisionHash = decisionEntry.body_hash; ``` `decision_hash` is one of the five fields of `SpendRequest`, next to the mandate id, the amount, the invoice hash and the ref. [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md) shows the whole request. The vault checks only the hash's form, 64 lowercase hex, stores it with the spend and returns it in `get_spend`. The `spend_held` and `spend_submitted` events carry it too. The payment entry points the other way: its `refs.decision_body_hash` holds the same hash. The vault never de-duplicates `decision_hash`. The verifier does: check d needs every spend on chain to have its own decision in the ledger, with the same invoice hash, mandate and amount. ### The committed test ledger The test fixture [`valid-snapshot.json`](https://github.com/probarion/probarion/blob/main/packages/core/test/fixtures/valid-snapshot.json) holds a ledger of five entries signed with test keys, for the vault `x.vaults.probarion.testnet`. It is test data, not a real vault. | Seq | Kind | What it records | In the range of | | - | - | - | - | | 0 | decision | Allow 25 test USDC under mandate 1, decided with a model | Anchor 1 | | 1 | payment | Spend 1 paid. Its `refs.decision_body_hash` is the body hash of entry 0 | Anchor 1 | | 2 | decision | Allow 40 test USDC under mandate 2, decided with a model | Anchor 2 | | 3 | payment | Spend 2 burned for a Base payee | Anchor 2 | | 4 | decision | Refuse by code, `mandate.payee_mismatch`. No spend points to it | Anchor 3 | On the fixture's chain side, spend 1 carries the body hash of entry 0 as its `decision_hash`, and spend 2 carries the body hash of entry 2. Anchor 1 has count 2 and the link of entry 1 as its root, anchor 2 has count 4 and the link of entry 3, and anchor 3 has count 5 and the link of entry 4. ## Anchors: pinning the ledger on chain `anchor(seq, root, count)` stores one record on the vault. `count` counts every entry from seq 0, and `root` is the link of entry `count - 1`. An anchor's range is the entries it adds: from the previous anchor's count up to entry `count - 1`, or from entry 0 for the first anchor. `contracts/vault/src/anchors.rs` ```rust pub fn anchor(&mut self, seq: u64, root: String, count: u64) { let signer_pk = env::signer_account_pk(); let by_ledger_key = env::predecessor_account_id() == env::current_account_id() && self.ledger_key.as_ref() == Some(&signer_pk); if !by_ledger_key { fail(ErrorCode::NotLedgerKey) } if seq != u64::from(self.anchors.len()) + 1 { fail(ErrorCode::BadSeq) } let previous_count = self .anchors .get(self.anchors.len().wrapping_sub(1)) .map_or(0, |a| a.count); if !is_hex64(&root) || count == 0 || count < previous_count { fail(ErrorCode::InvalidInput) } // Store the signing key with the anchor so a later key rotation keeps this range verifiable. self.anchors.push(Anchor { root: root.clone(), count, at_ns: env::block_timestamp(), ledger_key: signer_pk, }); VaultEvent::LedgerAnchored { seq, root, count }.emit(); } ``` The contract checks, in this order: | What it checks | Refusal | | - | - | | The call comes from the vault account itself, signed with the vault's current ledger key. Before a ledger key is set, nothing can anchor, and another account with the same public key is refused. | [`NotLedgerKey`](https://probarion.xyz/docs/build/errors.md#NotLedgerKey) | | `seq` is the number of anchors so far plus 1. The first anchor is 1, with no gaps and no repeats. | [`BadSeq`](https://probarion.xyz/docs/build/errors.md#BadSeq) | | `root` is 64 lowercase hex, `count` is above 0, and `count` is at least the previous anchor's count. An equal count is allowed. | [`InvalidInput`](https://probarion.xyz/docs/build/errors.md#InvalidInput) | The vault keeps the `root`, the `count`, the block time and the ledger key that signed, and emits `ledger_anchored` with the `seq`, `root` and `count`. `list_anchors` returns each anchor with its `seq`, and the SDK's `anchors.latest()` returns the last one. `anchor` takes no deposit. Two numbers are called `seq`. A ledger entry's `seq` starts at 0 and counts entries. An anchor's `seq` starts at 1 and counts anchors. In the test ledger above, anchor 3 has count 5 and the link of entry 4 as its root. Its range is entry 4 alone: entries 0 to 3 are the ranges of anchors 1 and 2. ### What the SDK anchors After every cycle, refusals included, the SDK anchors the whole tail: `root` is the link of the last entry, `count` is the length of the ledger, and `seq` is one above the last anchor. The ledger key signs it, with 30 Tgas and no deposit. - It never throws. A failed anchor is logged, and the next cycle anchors every entry up to its own last one. - On `BadSeq` it reads the last anchor from the chain once and tries again. - When the chain's last count equals the length of the ledger, there is nothing to anchor. - When the chain's last count is above the length of the ledger, this ledger is not the one the vault anchored. The SDK warns with `LEDGER_FORK_RISK` and does not anchor. ### Setting the ledger key The ledger key must be on the vault before the first anchor. The SDK also refuses to run `process()` or `requestSpend()` until the vault has a ledger key that matches the SDK's `ledgerKey`, because entries signed by any other key would fail check a. The owner sets it with `set_ledger_key`, and the CLI prints that transaction for the owner to sign: ```bash node packages/cli/dist/bin.js --network testnet ledger set-key --vault acme.vaults.probarion.testnet --pk ed25519:... ``` > **Owner signs** > > The owner signs `set_ledger_key` from their own wallet, with exactly 1 yoctoNEAR attached. The CLI writes the arguments to a file and prints the transaction: it never signs or sends. The key can call only `anchor`, and it gets a gas allowance of 0.25 NEAR unless `--allowance` says otherwise. > > _The owner signs this transaction. Probarion never signs it._ Each anchor keeps the ledger key that signed it, so a range anchored before a rotation stays verifiable against its own key. Entries after the last anchor must be signed by the new key, so anchor the ledger before you rotate. [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) covers the rotation. ## What a rewrite looks like These are the checks that read the ledger's signatures, links and anchors, and tie it to the spends on chain: ### a. Signatures (`ledger-signatures`) **Proves.** Every entry's `body_hash` recomputes from its body, and its `sig` is a valid ed25519 signature by the ledger key the chain names for its range: that anchor's `ledger_key`, or `config.ledger_key` after the last anchor. The current ledger key is an anchor-only function-call key on the vault. **Does not prove.** That the entries link into one chain (check b), or that any decision was right. ### b. Hash links (`hash-links`) **Proves.** Every `body_hash` and link recomputes, every entry names this vault, seq counts up from 0, and each `prev_link` chains back to the zero link. **Does not prove.** Who signed the entries (check a), or that the chain is the one the vault anchored (check c). ### c. Anchors (`anchors`) **Proves.** Each anchor's root is the link of the entry it names, the anchor seqs run 1, 2, 3 and so on, and the counts never go back. **Does not prove.** The entries after the last anchored count, which the pass detail counts, or which entry inside a failed range is wrong (checks a and b name it). ### d. A decision for every spend (`spends-have-decisions`) **Proves.** Each on-chain spend's `decision_hash` is the `body_hash` of one decision entry with the spend's invoice, mandate and amount, and the verdict `allow` (or `hold`, only for a spend that was held). No decision covers two spends. **Does not prove.** That the decision was a good one (the verifier does not run the policy again), or that the invoice is real. A spend from before the ledger existed is exempt when the snapshot lists its hash in `pre_ledger_decision_hashes`, and each listed hash exempts one spend at most. What each kind of change does: | What changed | What the verifier says | | - | - | | One field of an entry's body, not signed again | Checks a and b fail at that entry. If it is a decision that a spend points to, check d fails too when its invoice hash, mandate or amount no longer matches the spend, or its verdict is neither `allow` nor, for a held spend, `hold`. | | An anchored range rewritten, signed again with the ledger key and linked again | Checks a and b pass the rewritten entries. Their links have changed, so the anchor's root no longer matches: check c fails and names the first entry of that anchor's range. | | Entries cut below an anchored count | Check c fails: the anchor's count is above the number of entries in the ledger. It names the first missing entry. | The landing page's ledger test makes three edits to the committed test ledger, and `apps/web/test/site-tamper.test.ts` pins what the real verifier says about each: | Edit | Checks that fail | First broken entry | | - | - | - | | Entry 2's amount, 40 USDC changed to 0.000001 USDC | a, b, d | 2 | | Entry 4, the refusal, erased | c | 4 | | Entry 4's verdict, refuse changed to allow | a, b | 4 | Erasing the refusal breaks no signature and no link. Only anchor 3, whose count is 5, shows that an entry is missing. ### The gap after the last anchor > **Warning** > > Entries after the last anchor are not pinned on chain yet. Check a still checks them against the vault's current ledger key, and check b checks their links. But nothing on chain fixes how many there are. A copy of the ledger that drops them fails no check, unless a spend on chain points to a decision among them, which check d catches. This follows from the checks; no test shows it yet. The SDK anchors after every cycle, so between cycles the gap is empty unless an anchor failed. Check c says how many entries are in the gap. On the committed test ledger, every entry is anchored: ```text c anchors PASS 3 anchors match the ledger; 0 entries after the last anchored count (5) are not chain-attested ``` ### Offline and live runs `verify --offline ` checks a snapshot against itself: the chain data comes from the same file as the ledger. An edit that was not signed again fails. A forged snapshot, with entries signed again and chain data edited to agree, passes. Only a live run with `--vault` reads the anchors and the spends from the chain, so take the snapshot yourself or run live: ```bash node packages/cli/dist/bin.js --network testnet verify --vault acme.vaults.probarion.testnet --ledger .probarion/ledger.jsonl ``` [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md) walks through a run. ## Where the ledger lives, and one writer per vault ### In the SDK and the CLI `process()` and `requestSpend()` write through a `LedgerStore`. By default it is a `FileLedgerStore` at `.probarion/ledger.jsonl`, relative to the working directory, or at `PROBARION_LEDGER_PATH` when that is set. A blank value counts as unset. Pass `ledger` in the options to use another store; `InMemoryLedgerStore` is for tests. - The file has one line per entry, each line `JSON.stringify(entry)`. - Each append takes a lock file next to the ledger, writes the line and syncs it to disk before it returns. The lock uses a hard link, so the ledger must sit on a local file system that supports them, such as NTFS, ext4 or APFS. On FAT, exFAT and some network shares the append fails with an error: the store has no weaker lock to fall back to. - A torn last line from a crash is an error that names the line. Nothing is dropped or repaired: you inspect the file. The SDK reads its own ledger too. Before it decides, `process()` or `requestSpend()` reads every entry. An earlier payment of the same invoice whose outcome is unknown refuses the cycle by code, with `ledger.unresolved`, until it settles: until the chain shows that spend, or for 48 hours. The body holds no ref, so the match is by invoice hash. The CLI reads the same file. `verify` takes `--ledger ` and defaults to `PROBARION_LEDGER_PATH`, then `.probarion/ledger.jsonl`. `ledger export` copies the ledger to a new file and never writes over one: ```bash node packages/cli/dist/bin.js ledger export --out ledger-copy.jsonl ``` ### In the web console The console keeps each vault's ledger in its database, in the table `ledger_entries`. Hosted Payables, the console's reference agent that pays invoices, writes its entries there, signed with a ledger key that Probarion's server holds. Triggers refuse every update and delete, for every role, the table owner included. Every write goes through `ledger_append`, a compare-and-append. The SDK's own code builds and signs each entry, so a row gives the same line that `FileLedgerStore` writes. The database guards the shape of the chain; the verifier still judges the signatures and the anchors. A vault's proof page serves that ledger as `/proof//ledger.jsonl`, to the vault's members, or to anyone once its owner makes the proof public. The public proof page is Live for the testnet demo vault, `demo.vaults.probarion.testnet`, whose proof is public. See [What works today](https://probarion.xyz/docs/start/what-works-today.md#public-proof-page). ### One writer per vault Run one ledger writer per vault. - **Two writers on one ledger file.** Each decision is appended at exactly the position the cycle computed, compared under the store's lock. The writer that finds the head moved gets [`LedgerConflictError`](https://probarion.xyz/docs/build/errors.md#LedgerConflictError): it writes no entry and sends no spend, and the SDK never retries it by itself. - **Two writers with two ledgers.** They fork the vault's history. Apart from the ledger key and the form of `root` and `count`, the contract checks only that anchor seqs are consecutive and that counts do not go back. So it accepts both writers' anchors, interleaved, and check c fails at the first range anchored from the other ledger. This follows from the code; no test shows it yet. A cycle that finds the chain's count above its own ledger's length warns with `LEDGER_FORK_RISK` and does not anchor. - **A store that ignores the position.** A custom `LedgerStore` that does not compare before it appends is caught after the append: the SDK throws [`LedgerError`](https://probarion.xyz/docs/build/errors.md#LedgerError) and sends no spend. The console's database is a ledger of its own. If hosted Payables runs on a vault, give an SDK agent a vault of its own. [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md#one-ledger-writer-per-vault) says why. ## Where to go next - [Verify a vault](https://probarion.xyz/docs/trust/verify-a-vault.md): run the checks on a vault yourself. - [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md): what each of checks a to i proves, and what it does not. - [Security model](https://probarion.xyz/docs/trust/security-model.md): who holds the ledger key, and what each key can do. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md): the ledger key, and how to rotate it. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): what the contract checks before it accepts a spend. - [Errors](https://probarion.xyz/docs/build/errors.md#BadSeq): `BadSeq`, `NotLedgerKey` and the SDK's ledger errors. - [What works today](https://probarion.xyz/docs/start/what-works-today.md): the status of each part. - [Glossary](https://probarion.xyz/docs/resources/glossary.md): the terms on this page. ## What this does not do - It does not show that an invoice is real. The ledger stores the invoice's hash, not its text. - It does not show that a decision was a good one. The verifier checks that each spend has its own decision; it does not run the policy again. - It does not pin the entries written after the last anchor. Until an anchor's range includes them, only their signatures and links are checked, and nothing on chain fixes how many there are. - It does not stop whoever holds the ledger key from signing new entries. A live verify run catches a change to an anchored range. - It does not record approvals. They happen on chain, and the spend's own record shows that it was held. - Offline, it proves nothing about the chain. `verify --offline` checks a snapshot against itself: take the snapshot yourself, or run live. - It does not merge two ledgers. Two writers fork the history, and the SDK only warns. - `v0.probarion.testnet` has no ledger, so `verify` fails checks d and h there. The testnet demo vault, `demo.vaults.probarion.testnet`, has one, anchored 12 times by 09:52 UTC on 2026-11-12, when it held 17 entries, and the proof page's snapshot of it at 10:28 UTC passes checks a to i offline ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-2026-11-12-video.txt)). The mainnet vault, `v0.probarion.near`, has no ledger either. Its one spend's decision is pre-ledger, and check d exempts it, so a live `verify` of it passes checks a to h and skips check i. --- # Install the packages > Install the Probarion SDK, core and CLI 0.1.1 from npm on Node 24, or build them from source with pnpm 10, then check the build and import it. Install the SDK, core and CLI packages from npm for your own project, or build them from a clone for the repository's fixtures, scripts and evaluation. Then check that they work and import the SDK. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/install This page installs three Probarion packages, `@probarion/core`, `@probarion/sdk` and the `probarion` CLI, at version 0.1.1 from npm. Install them from npm to use them in your own project. Build them from a clone of the repository to run the repository's fixtures, scripts and tests, or the [evaluation](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md). > **On npm** > > Release 0.1.1 of `@probarion/core`, `@probarion/sdk` and `probarion` is on npm, under the `latest` tag. It was published on 2026-11-11 from the tag `sdk-v0.1.1` on `main`, by the repository's release workflow, with npm provenance. Release 0.1.0 came out on 2026-11-09. The packages are an unaudited beta, and a 0.x minor release may change their API. ## Requirements - **Node.js 24 or newer.** The `engines` field of every package says `node >=24`, and the repository's `.nvmrc` names 24, for a version manager that reads it. - **ES modules.** The packages are ESM only, with no CommonJS build: load them with `import`. - **bash, zsh or PowerShell.** Each command sits on its own line, so Windows PowerShell 5.1, which has no `&&`, runs them too. A build from source also needs: - **pnpm 10.** The root `package.json` accepts pnpm `>=10 <11` and names `pnpm@10.34.9` as its package manager. - **git**, for the clone. Optional: `@nearai/inference-sdk`, which only the online attestation check uses (`verify --online` in the CLI, `verify({ online: true })` in the SDK), needs Node 24.22.1 or newer. The CLI lists it as an optional dependency, so npm, and `pnpm install` in a clone, install it with the CLI, unless they cannot: an optional dependency that fails to install is left out. The SDK lists it as an optional peer dependency, which npm does not install: add it to your project to run the online check from your code. Everything else runs on any Node 24. You need no Rust, wallet, NEAR account or key to install or build. Rust is needed only for the contracts: the contract tests that `pnpm verify` runs, the contract build (`pnpm contracts:build`, with cargo-near) and the sandbox tests (`pnpm test:sandbox`). Check your versions first. `node --version` prints v24 or later, and, for a build from source, `pnpm --version` prints a 10.x version: ```bash node --version pnpm --version ``` ## Install from npm In your project's folder, install the SDK and the core library together, since the SDK's examples import from both: ```bash npm install @probarion/sdk @probarion/core ``` For the core library alone, with the policy engine, the ledger and the verifier's checks and no network code: ```bash npm install @probarion/core ``` For the CLI, install it globally and check its version: ```bash npm install -g probarion probarion --version ``` ```text 0.1.1 ``` Or run it without installing, from your project's folder, as `npx probarion` followed by the command: ```bash npx probarion --help ``` `npx probarion` also runs at the root of a clone, whose own `package.json` is named `probarion-monorepo`. There it runs release 0.1.1 from npm, not the code in the clone. To run the CLI a clone builds, use `node packages/cli/dist/bin.js`, as the rest of this page does. The CLI's commands are the same however you run it: `probarion verify --help` from npm is `node packages/cli/dist/bin.js verify --help` in a clone. To sign what the CLI prints, you also need `near-cli-rs`, the `near` command. The MCP server `@probarion/mcp` is on npm from 0.1.1 too, and the official MCP registry lists it as `io.github.probarion/probarion`. It runs as `npx -y @probarion/mcp@0.1.1`, with no clone and no global install, and [its README](https://github.com/probarion/probarion/tree/main/packages/mcp) sets it up for each MCP client. ## Build from source 1. **Open a shell in the folder where you keep projects.** The clone goes into a new `probarion` folder there. 2. **Run the lines of one tab, in order.** The "Everything" tab starts after the clone: for it, run the first line of "Packages only" first, then the lines of "Everything". **Packages only** ```bash git clone https://github.com/probarion/probarion cd probarion pnpm install pnpm --filter "{packages/cli}..." build ``` **Everything** ```bash cd probarion pnpm install --frozen-lockfile pnpm build ``` "Packages only" clones the repository, installs, and builds the CLI together with the packages it depends on, `@probarion/core` and `@probarion/sdk`, in build order. The proof page, the security page and the console's CLI fallback need none of this: they print `npx probarion` commands. Only a mainnet pre-ledger check on the proof page also reads the repository's evidence files, so for it the page says to clone the repository first. "Everything" is the build from the repository README. It installs exactly what the committed lockfile names, and its `pnpm build` builds every package of the workspace, the Next.js website in `apps/web` included. The README timed that route at about three and a half minutes on a first install: 2 min 42 s to install, 34 s to build. 3. **Update an older clone.** The CLI and your scripts run the built files in each `dist` folder, which change only when you build. In the `probarion` folder, pull, install and run the build line again (`pnpm build` instead, if you used the "Everything" tab): ```bash git pull pnpm install pnpm --filter "{packages/cli}..." build ``` ## Check the build Run these from the root of the clone. None of them needs a key or a wallet, and none sends anything. **The version.** A clone builds the version in `packages/cli/package.json`, which can be ahead of the release on npm: ```bash node packages/cli/dist/bin.js --version ``` ```text 0.1.1 ``` **The commands.** The help lists the program options and the commands. This output was captured through a pipe, so its lines wrap at 80 columns; a terminal wraps them to its own width: ```bash node packages/cli/dist/bin.js --help ``` ```text Usage: probarion [options] [command] Spend by mandate. Prove every payment. (Unaudited beta.) Options: -V, --version output the version number --network mainnet | testnet (default: NEAR_NETWORK, else testnet) --rpc comma-separated NEAR RPC URLs (default: NEAR_RPC_URLS, else the network defaults) -h, --help display help for command Commands: vault vault commands (owner transactions are printed, never signed) mandate mandate commands (owner transactions are printed, never signed) agent agent keys ledger ledger commands spend spend commands (transactions are printed, never signed) check [options] dry-run check_spend for an invoice: a free view call; nothing is signed or sent verify [options] verify a vault's ledger signatures, hash links, anchors, decisions, attestations, keys, burn memos and factory version help [command] display help for command ``` Each command has its own help, such as `node packages/cli/dist/bin.js verify --help`. Program options can come before the command, as the console writes `node packages/cli/dist/bin.js --network testnet vault create ...`, or after it, as the proof page writes `verify --network testnet --vault `. The [CLI README](https://github.com/probarion/probarion/blob/main/packages/cli/README.md) describes every command. **The offline verifier.** The fixture is a committed snapshot of a ledger and its chain state, signed with test keys. It is not a live vault, and the check reads no network: ```bash node packages/cli/dist/bin.js verify --offline packages/core/test/fixtures/valid-snapshot.json ``` The report starts with `Probarion verify: x.vaults.probarion.testnet (testnet)` and the time it ran, gives checks a to i a `PASS` each, and ends with these lines. The command exits 0. ```text 9 passed, 0 failed, 0 unproven RESULT: PASS ``` [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md) runs `verify` on a live vault. ## Use the SDK from your code With the packages from npm, your code imports them by name, as in `import { Probarion } from "@probarion/sdk";`. A script in the root of a clone imports the built files by path instead: | Package | Entry point | From npm | From the root of a clone | | - | - | - | - | | `@probarion/sdk` | `.` | `@probarion/sdk` | `./packages/sdk/dist/index.js` | | `@probarion/sdk` | `./testing` | `@probarion/sdk/testing` | `./packages/sdk/dist/testing/index.js` | | `@probarion/core` | `.` | `@probarion/core` | `./packages/core/dist/index.js` | Each entry point has its type declarations beside it. To import `@probarion/sdk/testing` from TypeScript, set `"moduleResolution"` to `"NodeNext"` or `"Bundler"`. `./testing` holds test doubles: a fake chain, a fake NEAR JSON-RPC server and a fake NEAR AI Cloud. A test that passes against them does not show that a real vault behaves the same. The importing file must be an ES module: an `.mjs` file, or a `.js` file under a `package.json` that sets `"type": "module"`. The root `package.json` of the clone sets it, so a `.js` or `.mjs` file in the root of the clone is an ES module. **A read-only example.** This is step 4 of the README's evaluation, saved as a file in the root of the clone. With the packages from npm, save it in your project and make its first line `import { Probarion } from "@probarion/sdk";`. An instance with no keys and `model: null` lists the mandates of the testnet vault `v0.probarion.testnet` and dry-runs a spend of 2 USDC and one of 6 USDC with the contract's free `check_spend` view. It signs nothing and sends nothing. `read-vault.mjs` ```js import { Probarion } from "./packages/sdk/dist/index.js"; const vault = new Probarion({ network: "testnet", vault: "v0.probarion.testnet", model: null }); for (const m of await vault.mandates.list()) console.log(`mandate ${m.id} "${m.label}": ${m.status}, pays ${m.payee.account_id ?? m.payee.address}, at most ${m.max_per_payment} per payment`); const invoiceHash = "ab".repeat(32); console.log("2 USDC:", await vault.checkSpend({ mandateId: 1, amount: "2000000", invoiceHash, ref: "README-1" })); console.log("6 USDC:", await vault.checkSpend({ mandateId: 1, amount: "6000000", invoiceHash, ref: "README-1" })); ``` ```bash node read-vault.mjs ``` The repository README records this output. The script reads the live vault, so its output changes when the vault changes or when its mandates expire, as [Evaluate in 5 minutes](https://probarion.xyz/docs/start/evaluate-in-5-minutes.md) also warns: ```text mandate 1 "smoke near": active, pays probarion-payee.testnet, at most 5000000 per payment mandate 2 "smoke base": active, pays 0x1111111111111111111111111111111111111111, at most 5000000 per payment 2 USDC: { ok: true, code: null, would_hold: false } 6 USDC: { ok: false, code: 'OverPerPayment', would_hold: false } ``` Amounts are strings in USDC base units, with 6 decimals, so `"2000000"` is 2 USDC. Mandate 1 allows at most 5 test USDC per payment, so the contract accepts 2 and refuses 6, until the mandate expires. The smoke mandates expire on 2026-12-01, 30 days after the 2026-11-01 smoke run that created them. After that both dry runs should answer [`MandateExpired`](https://probarion.xyz/docs/build/errors.md#MandateExpired), because the contract checks expiry before the per-payment limit, and `mandates.list()` still prints `active`, since a mandate's status does not count its expiry. This follows from the contract's code; no test shows it on this vault yet. Run the SDK in Node, on your server: its default ledger store is a file, and the agent and ledger keys stay server-side. The SDK is an unaudited beta, and a 0.x minor release may change its API. In a clone, a package of the clone's pnpm workspace that depends on the SDK also imports it by name, as the [SDK README](https://github.com/probarion/probarion/blob/main/packages/sdk/README.md) notes. ## Configure [`.env.example`](https://github.com/probarion/probarion/blob/main/.env.example) lists these variables for the SDK and the CLI, most with a comment. The Read by column says which package reads each one from the process environment. Where the SDK does not read one, your code reads it and passes the value as an option, such as `network`, `vault`, `agentKey`, `ledgerKey` or `model.apiKey`. Neither package loads a `.env` file by itself: set the variables in the shell that runs your code, or pass the values as options. An empty value counts as unset. | Variable | Read by | What it sets | | - | - | - | | `NEAR_NETWORK` | CLI | The network when `--network` is not given: `mainnet` or `testnet`. Unset: `testnet`. The SDK takes `network` as an option. | | `NEAR_RPC_URLS` | SDK, CLI | Comma-separated RPC URLs, in failover order. The SDK's `rpcUrls` option and the CLI's `--rpc` take precedence. Unset: the network's default RPCs. | | `NEAR_ARCHIVAL_RPC_URLS` | SDK, CLI `verify` | Archival RPCs for burn transactions older than about 2.5 days. The CLI's `--archival-rpc` takes precedence. Unset: FastNEAR's archival node for the network. | | `PROBARION_VAULT_ID` | CLI | The default for `--vault`. The SDK takes `vault` as an option. | | `PROBARION_FACTORY_ID` | CLI `vault create` | The factory that creates the vault. Unset: the network's factory. | | `PROBARION_LEDGER_PATH` | SDK, CLI `verify` and `ledger export` | The ledger file. Unset: `.probarion/ledger.jsonl` in the working directory. | | `PROBARION_AGENT_KEY` | CLI `check` | The SDK does not read it: your code passes it as `agentKey`. Without `--agent-pk`, `check` checks as the public key of this secret, and never prints the secret. | | `PROBARION_LEDGER_KEY` | Neither | Your code passes it as `ledgerKey`. | | `NEARAI_API_KEY` | Neither | Your code passes it as `model.apiKey`, for the optional model step. With `model: null` the code decides alone. | | `NEARAI_MODEL` | SDK | The NEAR AI Cloud model when `model.model` is not given. | > **One setting for both networks** > > `NEAR_RPC_URLS` and `NEAR_ARCHIVAL_RPC_URLS` replace the RPCs of whichever network you pass, and `.env.example` sets both to testnet URLs. With those values, a run with `--network mainnet` still talks to testnet RPCs, since nothing in the packages checks which chain an RPC serves. This follows from the code; no test shows it yet. Unset both, or set mainnet URLs, before a mainnet run. `.env.example` also sets `PROBARION_FACTORY_ID` and `PROBARION_VAULT_ID` to testnet accounts, which the CLI uses on any network: with them, `vault create` targets the testnet factory and the other commands default to the testnet vault. Unset them too, or set mainnet values. The `_MAINNET` and `_TESTNET` names in `.env.example` are for the web app only: the SDK and the CLI read the unsuffixed names. NEAR testnet is live today. On mainnet one small vault runs, `v0.probarion.near`, and no mainnet factory is deployed: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). ## Common mistakes - **An engines or version error during the install.** The packages need Node 24 or newer, and a build from source needs pnpm 10 too. Check `node --version` and `pnpm --version`. - **A script cannot load the packages.** There is no CommonJS build. Load them with `import` from an ES module: an `.mjs` file, or a `.js` file under a `package.json` that sets `"type": "module"`, as the root of the clone does. - **TypeScript cannot find `@probarion/sdk/testing`.** Set `"moduleResolution"` to `"NodeNext"` or `"Bundler"` in your `tsconfig.json`. - **`npx probarion` answers "could not determine executable to run".** It ran at the root of an older clone, whose own `package.json` was still named `probarion`. Run `git pull` in the clone, run it from your project's folder, or, in the clone, run `node packages/cli/dist/bin.js`. - **Node cannot find `packages/cli/dist/bin.js`.** Run the CLI from the root of the clone, after the build line. The `dist` folders exist only after a build. - **The build takes long.** `pnpm build` builds the website too. For the SDK and the CLI alone, run `pnpm --filter "{packages/cli}..." build`. - **An older clone acts on old code.** Pull, then run `pnpm install` and the build line again, as in step 3 of the build. - **An owner command the CLI printed fails in cmd.exe.** The printed commands are written for bash, zsh and PowerShell, and the CLI says so under each one: "Run it in bash, zsh or PowerShell (not cmd.exe)." - **A mainnet run reads testnet.** `NEAR_RPC_URLS` still holds the testnet URL from `.env.example`, or `PROBARION_FACTORY_ID` and `PROBARION_VAULT_ID` still name the testnet accounts from it. See [Configure](#configure). ## Next steps - [Developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md): one payment on testnet. Its step 1 is this build: with the clone built, start at its step 2, in the `probarion` folder. - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): the choices a production payment loop makes. - [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md): run the verifier on a live vault and read its report. - [Errors](https://probarion.xyz/docs/build/errors.md): the vault, SDK and HTTP error codes. - [What works today](https://probarion.xyz/docs/start/what-works-today.md#npm-release-011): the status of each part, the npm release included. - [For AI agents](https://probarion.xyz/docs/resources/for-ai-agents.md): the rules for an agent that builds on Probarion. - The [CLI README](https://github.com/probarion/probarion/blob/main/packages/cli/README.md) and the [SDK README](https://github.com/probarion/probarion/blob/main/packages/sdk/README.md) list every command and the rest of the API. --- # Let an agent pay > Wire your agent to a vault with process(): keys, invoice facts, code-only or model advice, one ledger writer, refs and retries, and outcomes. First payment? Start with the developer quickstart: this page turns your agent's invoice handling into a loop that pays each invoice at most once through process(). Status: Preview · Network: Testnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/let-an-agent-pay Your agent's invoice handling becomes a loop that pays through `process()`: each invoice is paid at most once (each decision's spend is sent once), one ledger writer keeps each vault's history, and no unknown outcome is ever paid twice. You need the SDK, from npm or built from a clone ([Install](https://probarion.xyz/docs/build/install.md)), and a testnet vault whose owner has signed the steps below. The [developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md) runs one payment end to end; this page covers what a loop that keeps paying has to get right. Everything here runs on NEAR testnet with test tokens. On mainnet only one small vault runs, `v0.probarion.near`, and no other can be created yet: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). This loop is Preview: no run of a loop of your own like this page's is recorded. The console's Payables runs the same `process()` on the testnet demo vault, and on 2026-11-12 two of its cycles each paid an invoice of 2.00 test USDC on its own, with an attested model ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). The outputs below come from the code and the SDK's own tests, which run against a fake chain. > **Key idea** > > The agent key can call only `spend`, and a spend names no payee: the vault pays the payee the mandate names. See [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). ## Before you start Make the agent key and the ledger key on your server with `agent keygen --out `. Each run saves the pair to a new file as `{ public_key, secret_key }` and prints only the public key. Send both public keys, and the purchase order or contract the agent pays against, to the vault owner. The owner then makes sure of these: 1. **The vault holds test USDC.** On a testnet vault's Top-up tab (`/console//topup`), the "Add test USDC" button mints 25 test USDC into the vault with no wallet. The Probarion team's mint-only key signs it, once a UTC day per vault and for up to 3 vaults per signed-in account a day. Without a faucet key the tab shows this instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." 2. **Your agent key is on the vault.** `agent add --pk ` prints the `add_agent` call. The vault adds a function-call key to itself that can call only `spend` and never attach a deposit. A vault holds at most 16 agent keys, and a key cannot be both an agent key and the ledger key. 3. **Your ledger key is the vault's ledger key.** `ledger set-key --pk ` prints the `set_ledger_key` call. It replaces any previous ledger key, and the key must not already be an agent key or the current ledger key. Do not run it on a vault that runs the console's hosted Payables: it would replace the ledger key Payables signs with. Give your agent a vault of its own, as [One ledger writer per vault](https://probarion.xyz/docs/build/let-an-agent-pay.md#one-ledger-writer-per-vault) explains. 4. **A mandate names your agent key and your purpose hash.** The owner signs it in the console, whose mandate form lists every agent key on the vault and hashes the purchase order file in the browser. Or the owner prints it with `mandate create`: its `--agent-pk` is your agent public key, its `--purpose` is the purchase order file, and it also requires `--payee`, `--max-per`, `--max-total`, `--uses`, `--expires`, `--approval-above` and `--label`. See [Mandates](https://probarion.xyz/docs/concepts/mandates.md). ```bash # On your server: each file keeps its secret, and keygen never overwrites one. node packages/cli/dist/bin.js agent keygen --out ../agent-key.json node packages/cli/dist/bin.js agent keygen --out ../ledger-key.json # For the owner. Illustrative: replace , and each ed25519:... with real values. node packages/cli/dist/bin.js --network testnet agent add --vault .vaults.probarion.testnet \ --pk ed25519:... --sign-as .testnet --out-dir probarion-tx node packages/cli/dist/bin.js --network testnet ledger set-key --vault .vaults.probarion.testnet \ --pk ed25519:... --sign-as .testnet --out-dir probarion-tx ``` The CLI never signs. Each owner command writes its arguments to a file in `probarion-tx/` and prints the near-cli-rs command under this line: ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: ``` > **Owner signs** > > The owner reviews each printed command and its arguments file, then signs it. `agent add` and `ledger set-key` each use 30 Tgas and attach exactly 1 yoctoNEAR. They give the key a gas allowance of 1 NEAR and 0.25 NEAR unless `--allowance` says otherwise, and the vault refuses an allowance of 0. `mandate create` attaches a storage deposit, 0.05 NEAR unless `--deposit` says otherwise, and the vault refunds the excess. > > _The owner signs this transaction. Probarion never signs it._ The console's Keys tab makes its own keys on Probarion's server, for hosted Payables. Your agent brings its own keys, so they go on the vault through the CLI. The console's Agent (MCP) tab (`/console//agent`) is the other way, built for the MCP server and tried on testnet on 2026-11-11, with the two keys `npx -y @probarion/mcp@0.1.1 init` made on the owner's machine. The owner pastes the two public keys there, and the page refuses a pasted secret key before anything is sent. The owner signs `add_agent` and `set_ledger_key` in their wallet, and the tab gives the settings for Claude Code, Claude Desktop, Cursor and Codex. It withholds the ledger key from a vault whose ledger another writer keeps. The MCP server `@probarion/mcp` is Live: it is on npm at 0.1.1, so `npx -y @probarion/mcp@0.1.1` runs it, as [its README](https://github.com/probarion/probarion/tree/main/packages/mcp) says. On 2026-11-11 a real MCP client, Claude Code, ran it from npm on a testnet vault: one spend paid, two were held until the owner approved them in the console, and the server's `verify` passed. On 2026-11-12 the same client asked for a fourth spend, which the vault held, and the owner rejected it in the console, so nothing was paid. The prompt gave the tool arguments, so the client read no invoice, and the decisions are rules-only ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/trial-vault/README.md)). Its `request_spend` tool calls this same SDK's `requestSpend`: the client names the mandate by id, so the mandate's caps, uses, expiry and agent key, the duplicate checks and the contract's checks apply, while no invoice text is read and the purpose rule passes. None of its tools takes an address. On the "Overview" tab, the "Your first agent payment" card offers its "Agent key and ledger key" step two ways, "Go to Keys" for hosted Payables and "Connect agent" for your own agent. It ticks the step once the chain shows an agent key and a ledger key that the console did not make. Skip "Go to Keys": a ledger key made there would replace yours, and the Keys tab asks before it does. The card goes away after the first payment. The [owner quickstart](https://probarion.xyz/docs/start/owner-quickstart.md) covers the vault itself. ## Create the instance `new Probarion(options)` makes the client for one vault. | Option | Value | What it does | | - | - | - | | `network` | `"testnet"` or `"mainnet"` | Required. The demo vaults are on testnet. On mainnet only `v0.probarion.near` runs, and no other mainnet vault can be created yet. | | `vault` | The vault account id | Required. | | `agentKey` | `"ed25519:..."` | The secret of your agent key, a function-call key on the vault account that can call only `spend`. Without it the instance is read-only. | | `ledgerKey` | `"ed25519:..."` | The secret of your ledger key, which can call only `anchor` and signs every ledger entry. | | `rpcUrls` | A list of RPC URLs | Tried in turn, with failover. Default: `NEAR_RPC_URLS` when set, for whichever network you pass, else the network's own list. | | `model` | `null`, or `{ provider: "near-ai-cloud", apiKey, model?, systemPrompt? }` | `null` or absent: the code decides alone. See [Choose who decides](https://probarion.xyz/docs/build/let-an-agent-pay.md#choose-who-decides). | | `ledger` | A `LedgerStore` | Default: a file at `PROBARION_LEDGER_PATH`, else `.probarion/ledger.jsonl`. A blank variable counts as unset. | | `confidenceFloor` | A number from 0 to 1 | Default 0.7. A model proposal below it holds the spend. | | `requestId` | Text | Written into the `request_id` of every entry this instance writes. Without it, each cycle gets a fresh UUID. | Your code passes both keys: the example below reads each `secret_key` from the file keygen wrote. The SDK does not read `PROBARION_AGENT_KEY`, `PROBARION_LEDGER_KEY` or `NEARAI_API_KEY` by itself, even though its error messages name them. Keep both secrets on the server and out of the repository. ### Errors you may see Each is a `ConfigError` ([Errors](https://probarion.xyz/docs/build/errors.md#ConfigError)). These come from the constructor: - `invalid secret key for : ...`: a key string is not a valid secret. The message names the vault account, never the key. - `model.apiKey (NEARAI_API_KEY) is required for the model step; pass model: null to decide rules-only`: `model` was set with an empty or missing `apiKey`. - `confidenceFloor must be a number from 0 to 1`. These come from `process()`, before it writes or sends anything: - `this instance is read-only: process() and requestSpend() need agentKey (PROBARION_AGENT_KEY), the vault's function-call key for spend`: no `agentKey` was passed. - `process() and requestSpend() need ledgerKey (PROBARION_LEDGER_KEY): every ledger entry is signed with it`: no `ledgerKey` was passed. - `the vault has no ledger key yet (set_ledger_key): entries signed now could never be verified`: the owner's `ledger set-key` has not landed. - `ledgerKey is , but the vault's ledger key is : its entries would fail verify check a`: the vault holds another ledger key, such as the console's. - `the agent key signs as , but this vault is : function-call keys live on the vault account itself`: only an injected sender (`deps.agentSender`) can sign as another account. ## Turn an invoice into facts `process()` takes `{ text, facts, ref }`. The SDK extracts nothing from the text: your agent reads the invoice and supplies the facts. | Field | Rule | | - | - | | `text` | The extracted invoice text, exactly as you read it. It is untrusted. | | `facts.invoice_hash` | `hashInvoice(text)`: the sha256 of the text's UTF-8 bytes, 64 lowercase hex. A PDF's file hash is not the invoice hash. | | `facts.amount` | A decimal string in USDC base units. USDC has 6 decimals, so `"25000000"` is 25 USDC. `"25.00"` or `"0"` is refused by code with `invoice.amount_invalid`. | | `facts.po_ref` | `null`, or the purchase order reference copied verbatim from `text`, with no trimming, case change or normalisation. An empty string counts as `null`. | | `facts.purpose_hash` | 64 lowercase hex, or `null`: the sha256 of the file the mandate was signed with. It chooses the mandate. | | `facts.payee_hint` | `null`, or what the invoice claims about the payee: `near_account`, `address`, `name`, and `chain` set to `base` or `solana`. It is never the destination. A hint that contradicts the mandate's payee is refused with `mandate.payee_mismatch`. | | `ref` | Your reference for this payment: 1 to 64 bytes of UTF-8 text. The vault accepts each ref once. | The SDK picks the mandate by `purpose_hash`: the lowest-id mandate with that purpose that your agent key can still use (not revoked, not expired, bound to this key, a use left, room under its total). When none qualifies it takes the lowest-id match anyway, so the refusal names the precise reason. With no match at all, the cycle is refused with `mandate.none`. A malformed call throws `ProbarionError` with code `InvalidInput` ([Errors](https://probarion.xyz/docs/build/errors.md#ProbarionError)) before anything is read, written or sent: an `invoice_hash` that is not `hashInvoice(text)`, an amount that is not a string, a `po_ref` that is not in the text, a bad `purpose_hash`, `payee_hint` or `ref`. ### What the tests pass The SDK's tests build their invoices like this. For a string, `sha256Hex(text)` is the same value as `hashInvoice(text)`. The helper's default `po_ref` is `PO-1001`, so the text must contain `PO-1001` unless the test passes another `po_ref`: `packages/sdk/test/support/probarion-harness.ts` ```ts export function nearInvoice( text: string, ref: string, facts: Partial = {}, ): TestInvoice { return { text, ref, facts: { invoice_hash: sha256Hex(text), amount: "25000000", po_ref: "PO-1001", purpose_hash: SCENARIO_PURPOSE_NEAR, payee_hint: { near_account: SCENARIO_NEAR_PAYEE }, ...facts, }, }; } ``` ## Choose who decides - **`model: null`**: the code rules decide alone. Each decision entry records `decided_by: "rules-only"`. - **NEAR AI Cloud**: `model: { provider: "near-ai-cloud", apiKey }` asks a private model in a TEE for one proposal per invoice and records its attestation. `model` names a model id; without it the SDK reads `NEARAI_MODEL`, else picks an eligible private model from the catalog. When the model's proposal passes its attestation checks and is used, the decision entry records `decided_by: "model+rules"`. Otherwise, for example when the model call or its attestation fails, it records `"rules-only"`. The model only advises. It is shown the invoice text as data, the facts `invoice_hash`, `amount`, `po_ref` and `purpose_hash` (not your `payee_hint`), and the one mandate the code picked. The code rules still run on its proposal, and the contract checks the spend again: - A proposal to hold is recorded as rule `model.refused` with a hold. A proposal to refuse is `model.refused` with a refusal. - A confidence below `confidenceFloor` holds the spend with `model.low_confidence`. - An amount that differs from `facts.amount` is refused with `invoice.amount_invalid`. A payee hint that contradicts the mandate is refused with `mandate.payee_mismatch`, even when the invoice text talked the model into it. - A proposal that names another mandate is refused, with `invoice.po_mismatch` (or `mandate.none` when that mandate does not exist), so the model cannot move a payment to another mandate. - When NEAR AI Cloud fails, or its attestation fails the checks at this ledger position, the proposal is dropped and the code decides alone, with `model.unavailable` in the record. `outcome.modelUsage` gives the model id and token counts whenever the model answered. Pass the key only when you have one, so that a missing variable falls back to the code: ```js model: process.env.NEARAI_API_KEY ? { provider: "near-ai-cloud", apiKey: process.env.NEARAI_API_KEY } : null, ``` > **Note** > > The NEAR AI Cloud adapter is built and tested against a fake NEAR AI Cloud, and is Preview. On testnet, the Canary's ledger of 2026-11-10, published in the repository, holds two real attested decisions that pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/README.md#the-signed-ledger-published-2026-11-11), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/verify-online-2026-11-11.txt)). On the testnet demo vault, Payables made five more, which pass A1 to A7 offline; four of them, entries 10 to 13, also passed O1 and O2 online on 2026-11-12. The code refused three of them on 2026-11-11, and on 2026-11-12 it allowed the fourth and the fifth, both by `Qwen/Qwen3.8-27B`, and the vault paid each invoice, 2.00 test USDC, as spends 4 and 5 ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-online-2026-11-12.txt)). The TEE measurement is not pinned, so this does not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-a8-2026-11-12.txt)). [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md) explains how the code rules and the contract's checks fit together. ### Your own rules `probarion.rules.add(rule)` adds a rule to every decision. Its `id` starts with `custom.` and its `check(input, mandate)` returns a pass, a hold or a refusal with a detail. Custom rules run after the built-in ones and can only tighten the verdict: a rule that throws refuses. ## Run the loop 1. **Check** The input and both keys, before anything is read. 2. **Read** The vault's config, mandates and spends, and your whole ledger. 3. **Decide** Optional model advice, then the code rules. 4. **Send once** A decision entry, the free `check_spend`, then one `spend`. 5. **Record** A payment entry, then an anchor on chain. ### The cycle in code order 1. Check the input: a malformed call throws `InvalidInput`. 2. Require the keys: no `agentKey` or `ledgerKey` throws `ConfigError`. 3. Read the vault: its config, every mandate and every spend. 4. Check the ledger key: yours must be the vault's. 5. Read the whole ledger, to find the next position. 6. Look in the ledger for an unresolved earlier payment of the same invoice. 7. Pick the mandate by `purpose_hash`. 8. Ask the model, when one is set. This is skipped with `model: null`, with no matching mandate, or when an earlier payment of the invoice is unresolved. 9. Decide with the code rules. For a Base or Solana payee, the SDK first reads the vault's balance on NEAR Intents; an unreadable or short balance holds with `intents.balance_low`. 10. Write the decision entry at exactly the position read in step 5. 11. Unless the code refused, dry-run the spend with the contract's free `check_spend` view. A refusal there ends the cycle as `refused_by_contract`. A code hold that the contract would pay at once ends it as `held` with nothing sent. 12. Send `spend` once, signed by the agent key. It is never sent again. 13. Write the payment entry. If it cannot be written, the outcome still stands and carries `ledgerComplete: false`. 14. Anchor the ledger tail with the ledger key. A failed anchor is only logged: the next cycle anchors the whole tail. Once `spend` is sent, the cycle always returns an outcome, so when `process()` throws, no `spend` went out. That is inferred: it follows from the code, and no test shows it yet. A lost answer comes back as `unconfirmed`, as the SDK's tests show. ### One call at a time Calls on one instance run one at a time, in call order, so a burst of invoices queues inside the instance. Use one instance per vault in one process. A second instance or a second process is a second writer: see [One ledger writer per vault](https://probarion.xyz/docs/build/let-an-agent-pay.md#one-ledger-writer-per-vault). ### Wire it into your agent Illustrative, adapted from the SDK README: the imports point at the built files of a clone (with the packages from npm, import `@probarion/core` and `@probarion/sdk` by name), the vault is a placeholder, the keys come from the two key files, and `model: null` lets the code decide alone. Save it in the `probarion` folder and run it from there. The example invoice is for 2 USDC, so the developer quickstart's mandate (at most 5 USDC per payment, held for an approver above 2) pays it at once. A larger amount needs a mandate whose limits allow it and a vault that holds enough test USDC. `agent-pay.mjs` ```js import { readFile } from "node:fs/promises"; import { sha256Hex } from "./packages/core/dist/index.js"; import { Probarion, hashInvoice } from "./packages/sdk/dist/index.js"; // The secret_key of a file that agent keygen wrote. const secretOf = async (file) => JSON.parse(await readFile(file, "utf8")).secret_key; // One instance per vault in this process: its process() calls run one at a time. const probarion = new Probarion({ network: "testnet", vault: ".vaults.probarion.testnet", agentKey: await secretOf("../agent-key.json"), // can call only spend ledgerKey: await secretOf("../ledger-key.json"), // can call only anchor, and signs every ledger entry model: null, // the code decides alone }); // The purchase order file the owner signed the mandate with. const poBytes = new Uint8Array(await readFile("../po.txt")); // Your agent read the invoice and supplies the facts: the SDK extracts nothing. export function pay(invoiceText, { ref, amount, poRef }) { return probarion.process({ text: invoiceText, // untrusted: shown to the model as data and nothing else ref, // your reference; the vault accepts each ref once facts: { invoice_hash: hashInvoice(invoiceText), amount, // USDC base units: "2000000" is 2 USDC po_ref: poRef, // copied verbatim from invoiceText, or null purpose_hash: sha256Hex(poBytes), // the hash the mandate was created with payee_hint: null, // what the invoice claims; never the destination }, }); } const invoiceText = "Invoice INV-2026-001: 2.00 USDC for PO-1001"; const outcome = await pay(invoiceText, { ref: "INV-2026-001", amount: "2000000", poRef: "PO-1001" }); console.log(outcome.status, outcome.code, outcome.spendId, outcome.txHash); ``` A clean cycle writes two entries: the decision, then the payment. On an empty ledger they sit at positions 0 and 1, and on a ledger that already holds entries, such as the developer quickstart's, at the next two positions. This is what the SDK's own test expects from a first payment against its fake chain and fake NEAR AI Cloud: `packages/sdk/test/probarion.integration.test.ts` ```ts const text = "Invoice INV-1: 25.00 USDC for PO-1001"; const out = await pb.process(nearInvoice(text, "INV-1")); expect(out).toMatchObject({ status: "paid", spendId: 1, code: null, ledgerSeq: 1 }); ``` ## Handle the outcome `outcome.status` is one of the seven statuses `process()` returns. `SpendOutcomeStatus` also has `settled`, which `process()` never returns. Illustrative, one line per branch: `agent-pay.mjs` ```js switch (outcome.status) { case "paid": // the vault paid the mandate's NEAR payee: record spendId and txHash case "burned": // paid out through NEAR Intents to a Base or Solana payee: record spendId and txHash break; case "held": // spendId null: a code rule held it and nothing was sent; spendId set: an approver decides console.log(outcome.spendId === null ? `code hold: ${outcome.code}` : `held as spend ${outcome.spendId}`); break; case "refused_by_code": // a code rule said no (outcome.code is the rule id); nothing was sent case "refused_by_contract": // the vault said no (outcome.code is the vault code) break; case "failed": // definitely not paid; spendId set means the vault took the spend and its ref is used up break; case "unconfirmed": // it may have paid: never send it again; find the spend by its ref break; } ``` A contract hold waits for one of the vault's approvers, who approves or rejects it before the hold expires: see [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md). [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) has the full table, and [Errors](https://probarion.xyz/docs/build/errors.md) explains each vault code. > **Three rules for every loop** > > - Never send an `unconfirmed` spend again, least of all under a new ref. Wait, or find the spend by its ref. > - A `held` outcome with `spendId` null was never sent, and no call releases it. Change what caused the hold, then run the cycle again. > - A `failed` outcome with `spendId` set used up its ref. The retry needs a new `ref`. ### Find a spend by its ref `probarion.spends.get(id)` takes a spend id, not a ref, and the contract has no lookup by ref. To find a spend by its ref, read the vault's spend list. Illustrative, adapted from the SDK README; save it in the `probarion` folder too: `find-spend.mjs` ```js import { RpcClient, VaultClient, resolveRpcUrls } from "./packages/sdk/dist/index.js"; // Read-only: no sender, so it cannot sign anything. listAllSpends pages the contract's list_spends. const vault = new VaultClient(".vaults.probarion.testnet", new RpcClient({ urls: resolveRpcUrls("testnet") })); const spend = (await vault.listAllSpends()).find((s) => s.ref === "INV-2026-001") ?? null; console.log(spend === null ? "no spend with this ref" : `spend ${spend.id}: ${spend.status}`); ``` ## Refs and retries - **A ref is used once per vault, forever.** It is used up once the contract accepts a spend, held or paid, and it stays used even if the payout fails, the spend is rejected or the hold expires. A refused spend reverts every change, so its ref stays free, and a code refusal sends nothing at all. - **The SDK refuses a used ref by code.** Before anything is sent, the rule `invoice.duplicate` refuses a ref the vault has already used, and an invoice with a live spend (held, pending, paid or burned). The contract checks both again, as [DuplicateInvoice](https://probarion.xyz/docs/build/errors.md#DuplicateInvoice) and [DuplicateRef](https://probarion.xyz/docs/build/errors.md#DuplicateRef). - **A released invoice can be paid again.** When the vault releases a spend (failed, rejected or expired), the same invoice can go through under a new ref. - **An unconfirmed invoice waits.** After `unconfirmed`, the same invoice is refused by code with `ledger.unresolved` until the chain shows a spend with that decision's hash, a later payment entry records a known outcome, or 48 hours pass (`UNKNOWN_SETTLE_MS`). The refusal's detail names the time after which the invoice can be retried. The guard lives in your ledger: a writer with another ledger file does not see it. - **Dedupe by `spendId`.** On one instance, a second identical call is refused by code with `invoice.duplicate`. Two writers that make identical decisions (for example with the same `requestId`) can both report the same `spendId` as paid, for one payment. Never count two successes as two payments. The SDK's test for a failed payout shows the sequence: the first spend fails and is released, the same ref is refused by code, and the same invoice pays under a new ref. `packages/sdk/test/probarion.outcomes.test.ts` ```ts server.chain.hooks.payoutFailure = "ft_transfer_failed"; const { pb, ledger } = setup(server); const first = await pb.process(INVOICE()); expect(first).toMatchObject({ status: "failed", spendId: 1, code: "failed", ledgerSeq: 1 }); expect(typeof first.txHash).toBe("string"); expect(server.chain.spends[0]?.status).toBe("failed"); expect((await ledger.all())[1]?.body.contract).toEqual({ code: null, tx_hash: first.txHash, spend_id: 1, status: "failed", }); server.chain.hooks.payoutFailure = null; // The ref is consumed forever, even by a failed spend. const sameRef = await pb.process(INVOICE()); expect(sameRef).toMatchObject({ status: "refused_by_code", code: "invoice.duplicate" }); // A released spend frees its invoice: the same invoice under a new ref is a new decision. const retry = await pb.process({ ...INVOICE(), ref: "INV-1-retry" }); expect(retry).toMatchObject({ status: "paid", spendId: 2 }); ``` ## One ledger writer per vault The ledger is a file by default: `.probarion/ledger.jsonl` in the working folder, or the path in `PROBARION_LEDGER_PATH`. It holds one JSON entry per line. Each append takes a lock file beside it (`.lock`) and is synced to disk. The lock is made with a hard link, so the folder must be on a local disk whose file system supports hard links (NTFS, ext4, APFS), not FAT, exFAT or some network shares. - **Two writers on one file.** The cycle whose decision append finds that the ledger moved throws `LedgerConflictError` ([Errors](https://probarion.xyz/docs/build/errors.md#LedgerConflictError)) with `ledger moved since this decision was attested; retry (no entry was written and no spend was sent): ...`. Nothing is written or sent, and the SDK never retries it by itself. - **Two ledger files for one vault.** They fork its history. The contract accepts each writer's anchor as long as its count is not below the previous anchor's, and the anchor check of `verify` ([check c](https://probarion.xyz/docs/trust/verifier-checks.md#check-c)) then fails. That is inferred: it follows from the code, and no test shows it yet. A cycle that finds the vault anchored past its own ledger logs a warning and does not anchor. - **Your own store.** A `LedgerStore` you pass in, for example over a database, must honour the expected position on append. If it does not, the SDK stops with a `LedgerError` before it sends the spend. [The ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md) explains what each entry records. > **Not on a vault that runs hosted Payables** > > Do not also run the console's hosted Payables on the same vault. Payables keeps its own ledger for its vault, in the console's database, signed with a ledger key that Probarion's server holds. A vault has one ledger key, and `process()` refuses to run when its `ledgerKey` is not the vault's, so the two cannot both write to one vault. This is inferred: it follows from the code, and no test shows it yet. Give your SDK agent a vault of its own. ## Check your work Run the verifier from the same folder as your loop, so it reads the same default ledger file. Pass `--ledger ` when your ledger lives elsewhere. ```bash node packages/cli/dist/bin.js --network testnet verify --vault .vaults.probarion.testnet ``` The report starts with `Probarion verify: (testnet)`, lists each check, and ends with a `RESULT:` line that says PASS, FAIL or UNPROVEN. The exit code says the same: | Exit code | Meaning | | - | - | | 0 | Every check passes. | | 1 | A check fails, or verify could not run. | | 2 | No check fails, but at least one is unproven. | | 64 | A usage error: a missing, malformed or conflicting flag. | [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md) walks through a run. ## Common mistakes - **Sending an `unconfirmed` spend again under a new ref.** It may have paid. Find the spend by its ref, or wait for `ledger.unresolved` to clear. - **Retrying a `failed` spend with its old ref.** When `spendId` is set, the vault used the ref, and the SDK refuses it with `invoice.duplicate`. Give the retry a new ref. - **A second instance, a second process or a second ledger file on one vault.** The loser of a race throws `LedgerConflictError`, and a second file forks the history. - **`apiKey: process.env.NEARAI_API_KEY ?? ""`.** An empty key throws `ConfigError` when the variable is unset. Pass `model: null`, or set `model` only when the key exists. - **An amount in decimals, such as `"25.00"`.** It is refused with `invoice.amount_invalid`. Write `"25000000"`. - **A `po_ref` that was re-cased, padded or normalised (for example a no-break space turned into a space).** It throws `InvalidInput`. Copy it from the text byte for byte, or pass `null`. - **The hash of another file as `purpose_hash`.** No mandate matches, and the cycle is refused with `mandate.none`. Hash the file the mandate was signed with. - **[NotAgent](https://probarion.xyz/docs/build/errors.md#NotAgent) from the vault.** The mandate names your agent key, but the vault does not hold it yet: the owner's `agent add` has not landed. - **`refused_by_code` with `mandate.agent_mismatch`.** `agentKey` is not the key the mandate names, for example because the agent key was made again after you sent its public key. Pass the secret of the public key you sent the owner. ## Not available yet - No HTTP API for agents: the hosted invoice API needs a signed-in console session of the vault's owner or approver. An agent pays from its own process, through the SDK. The CLI sends no spend: `check` only dry-runs one, and its owner and approver commands only print transactions. [What works today](https://probarion.xyz/docs/start/what-works-today.md) lists the status of every part. ## Related pages - [Developer quickstart](https://probarion.xyz/docs/start/developer-quickstart.md): one payment end to end. - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): every status and code in detail. - [Errors](https://probarion.xyz/docs/build/errors.md): the vault, SDK and HTTP error codes. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md) and [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): what a mandate allows, and what happens to a held spend. - [The ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md): what each entry records. - [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md): run the verifier and read its report. - The [SDK README](https://github.com/probarion/probarion/blob/main/packages/sdk/README.md) lists the rest of the `Probarion` class. --- # Spend outcomes > Every status process() and requestSpend() return: what spendId, code and txHash mean, and what to do next. Never resend an unconfirmed spend. process() and requestSpend() return one SpendOutcome. Its status tells you whether money moved, and its spendId, code and txHash tell you what to do next. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/spend-outcomes > **Key idea** > > `unconfirmed` means the spend may have paid. Never send it again, under the same ref or a new one. Find the spend by its ref, or wait. Every cycle of `process()` and every call of `requestSpend()` ends in one `SpendOutcome`. Branch on `status` first, then read `spendId` and `code` to see where the spend ended and why. A problem that stops the cycle before it can end, such as a malformed argument, is thrown as an error instead: see [When process() throws](#when-process-throws). This page describes the SDK's code, and its tests show each outcome against a fake chain. The cycle in an agent of your own is Preview: no run of it there is recorded yet (see [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md)). The console's Payables runs `process()` on the testnet demo vault, and on 2026-11-12 two of its cycles ended `paid`: ledger entries 13 and 14, spend 4 ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model)), and entries 15 and 16, spend 5 ([the record](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze)). ## The SpendOutcome object The SDK exports the types `SpendOutcome`, `SpendOutcomeStatus` and `ModelUsage`, and the constant `UNKNOWN_SETTLE_MS`. `packages/sdk/src/spend-outcome.ts` ```ts export type SpendOutcomeStatus = | "refused_by_code" | "refused_by_contract" | "held" | "paid" | "burned" | "settled" | "failed" | "unconfirmed"; ``` | Field | Type | Meaning | | - | - | - | | `status` | `SpendOutcomeStatus` | What the cycle came to. See [Statuses](#statuses). | | `spendId` | `number`, or `null` | The vault's id for the spend, when the vault took it or the answer named it. | | `code` | `string`, or `null` | A rule id for an outcome decided by code, the bare vault code for a refusal by the contract, and otherwise what failed or what is unknown. Null for `paid`, `burned` and a contract hold. | | `txHash` | `string`, or `null` | The `spend` transaction's hash. Null when no transaction ran, when the sender does not know the hash, or when the SDK found the spend by its ref: see [Paid, burned and settled](#paid-burned-and-settled). | | `ledgerSeq` | `number` | The seq of the last ledger entry the cycle wrote. | | `decisionHash` | `Hex32` | The body hash of the cycle's decision entry. The spend carries the same value on chain as `decision_hash`. | | `modelUsage?` | `ModelUsage`, or `null` | The model id and its token counts (`model`, `promptTokens`, `completionTokens`) when the model answered and reported its token counts, else null. `requestSpend()` always returns null here. | | `anchorTxHash` | `string`, or `null` | This cycle's anchor transaction. Null when the cycle wrote no anchor: see [Ledger fields](#ledger-fields). It is returned only, never stored in an entry. | | `ledgerComplete?` | `false` | Present only when the cycle's payment entry could not be written. See [Ledger fields](#ledger-fields). | ## Statuses | Status | `spendId` | `code` | `txHash` | Money moved | Next action | | - | - | - | - | - | - | | `paid` | The spend's id | `null` | The spend transaction, or null when found by ref | Yes: USDC to the mandate's NEAR payee | Record `spendId` and `txHash`. | | `burned` | The spend's id | `null` | The spend transaction, or null when found by ref | Yes: out of the vault through NEAR Intents, for a Base or Solana payee | Record `spendId` and `txHash`. Never send it again, under the same ref or a new one: the burn is final for the vault. See [Paid, burned and settled](#paid-burned-and-settled). | | `held`, contract hold | The spend's id | `null` | The spend transaction, or null when found by ref | No. The amount is reserved. | An approver approves or rejects it. See [Contract hold](#contract-hold). | | `held`, code hold | `null` | The first rule that held | `null` | No. Nothing was sent. | Change what caused the hold, then run the cycle again. See [Code hold](#code-hold). | | `refused_by_code` | `null` | The first rule that refused | `null` | No. Nothing was sent. | Fix what `code` names, then run the cycle again. For `invoice.duplicate`, read the rule's detail first. See [Refused by code](#refused-by-code). | | `refused_by_contract` | `null` | The bare vault code | `null` after a `check_spend` refusal, the failed transaction after a `spend` panic | No | Look the code up in [Errors](https://probarion.xyz/docs/build/errors.md). See [Refused by the contract](#refused-by-the-contract). | | `failed` | Set when the vault took the spend and released it, else `null` | The spend's status, or why the transaction did not run or why its spend receipt failed | The spend transaction, or null when the spend was found by ref or no transaction ran | No. Definitely not paid. | Fix the cause. When `spendId` is set, retry under a new ref. See [Failed](#failed). | | `unconfirmed` | Set when the answer named one | What is unknown | Set whenever the sender knows it | Maybe | Never send it again. Find the spend by its ref, or wait. See [Unconfirmed](#unconfirmed). | | `settled` | - | - | - | - | None: a type member that `process()` never returns. | These are the seven statuses `process()` returns, with `held` split into its two kinds. `settled` is in the type for the later cross-chain release: a burn is not settled yet when the cycle ends, so `process()` never returns it. ## Held: two kinds A `held` outcome means one of two different things, and `spendId` tells them apart. The SDK's integration test pins both shapes, the contract hold first and then the code hold: `packages/sdk/test/probarion.integration.test.ts` ```ts expect(out).toMatchObject({ status: "held", spendId: 1, code: null }); expect(out).toMatchObject({ status: "held", spendId: null, code: "intents.balance_low", txHash: null, ledgerSeq: 0, }); ``` [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md) explains both kinds in full. ### Contract hold `spendId` is set and `code` is null. The vault holds every spend whose amount is above the mandate's `approval_above`. An amount equal to `approval_above` pays at once. The hold comes from the contract, not from `decide()`: the code rules never read `approval_above`, so a spend that passes them is sent, and the vault answers `held`. While the spend is held, the vault keeps the amount, one use and the invoice hash reserved for it, and its ref is used. One of the vault's approvers ends the hold with `approve` or `reject`, each with exactly 1 yoctoNEAR attached. One approver is enough: there is no quorum. The hold lasts the vault's `held_ttl_ns`, one value for the whole vault, set when the vault is created and at most 30 days. Past the spend's `held_until_ns`, `approve` is refused with [`HoldExpired`](https://probarion.xyz/docs/build/errors.md#HoldExpired), and anyone may call `expire_held` to release it. > **Approver signs** > > An approver signs `approve` or `reject` from their own wallet, with exactly 1 yoctoNEAR attached. The CLI's `spend approve` and `spend reject` print the transaction and never sign it. > > _An approver signs this transaction. Probarion never signs it._ The cycle that returned `held` is over. The approval runs in the approver's own transaction, so read the spend later with `probarion.spends.get(spendId)`. After an approval it reads `pending`, then `paid`, `burned` or `failed`. After a rejection or an expiry it reads `rejected` or `expired`. A rejected or expired spend frees its invoice, but its ref stays used, so a new attempt at the same invoice needs a new ref. ### Code hold `spendId` is null, `txHash` is null, and `code` is the first rule that held: | `code` | Holds when | | - | - | | `intents.balance_low` | The payee is paid through NEAR Intents, and the vault's balance of that asset inside intents is unknown or below the amount. | | `model.refused` | A model proposal whose attestation checks passed asked to hold. This one id covers every model action: `pay` passes, `hold` holds and `refuse` refuses. | | `model.low_confidence` | That trusted proposal's confidence is below `confidenceFloor`, 0.7 by default. | | `custom.*` | One of your own rules returned `hold`. | The SDK holds by code when the rules hold and `check_spend` says the vault would pay at once. It then sends nothing. The cycle writes its decision entry, with the verdict `hold`, and no payment entry. Nothing was sent and no call releases a code hold. No approver can act on it, because nothing is on chain. Change what caused the hold, then run the cycle again. Nothing reached the vault, so the invoice and the ref are still free for the new cycle. For `intents.balance_low`, the owner tops up the vault's balance inside NEAR Intents. On testnet the console cannot: its Top-up tab says "NEAR Intents and 1Click exist on mainnet only, so this page cannot top up a Base balance on testnet", and only `probarion.testnet` can mint the test tokens that stand in for the Base and Solana assets. This rule never holds a payment to a NEAR payee. [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md) explains the stand-ins. When the rules hold and the vault would hold the spend too, the SDK sends it and the vault holds it: the outcome is `held` with a `spendId`, and an approver decides. This follows from the SDK's code; no test shows it yet. ## Unconfirmed `spend` was sent and may have paid. The SDK makes one `spend` call per decision and never sends the spend again after an unconfirmed outcome, and it never reports an answer that might have paid as refused or failed. Inside that one call, the SDK's sender signs the spend again only when the node refuses the nonce and says it does not know the copy just sent: see [Paid, burned and settled](#paid-burned-and-settled). `txHash` is set whenever the sender knows it, and `spendId` when the answer named one. `code` says what is unknown: | `code` | What happened | | - | - | | `TxOutcomeUnknown` | The SDK's sender threw [`TxOutcomeUnknownError`](https://probarion.xyz/docs/build/errors.md#TxOutcomeUnknownError), and `txHash` is that error's hash. The same code comes back with `txHash` null when a sender the SDK does not know failed, because it may have sent the transaction. | | `pending` | The spend ran and its payout is still pending. `spendId` is set. | | `callback_failed` | The spend ran and a later receipt failed, or the result does not show which receipt failed. The payout may have gone out. | | `unreadable_value` | The transaction succeeded, but its return value is not a spend result. | | `DuplicateRef`, `DuplicateInvoice` | The vault answered with a duplicate code for a ref this cycle sent for the first time, and no spend holding that ref or invoice could be found: the RPC may be a block behind, or the lookup failed. | Do two things. Never send the spend again, least of all under a new ref. Then find the spend by its ref, or wait for the guard below to end. When the outcome also carries `ledgerComplete: false`, find the spend by its ref first, for the reason the warning under [The 48-hour guard](#the-48-hour-guard) gives. ### Find a spend by its ref > **Warning** > > `probarion.spends.get(id)` takes a spend id, not a ref, and the contract has no lookup by ref. To find a spend by its ref, read the vault's spend list. Illustrative. Run the file below from the root of a clone built as [Install](https://probarion.xyz/docs/build/install.md) shows. `find-spend.mjs` ```js import { RpcClient, VaultClient, resolveRpcUrls } from "./packages/sdk/dist/index.js"; const ref = "INV-1"; // the ref of the unconfirmed cycle // Read-only: no sender, so it cannot sign anything. listAllSpends pages the contract's list_spends. const vault = new VaultClient("acme.vaults.probarion.testnet", new RpcClient({ urls: resolveRpcUrls("testnet") })); const spend = (await vault.listAllSpends()).find((s) => s.ref === ref) ?? null; console.log(spend === null ? "no spend with this ref yet" : `spend ${spend.id}: ${spend.status}`); ``` `resolveRpcUrls` uses `NEAR_RPC_URLS` instead of the network's default endpoints when that variable is set, whichever network you pass. To be sure the spend you found is this cycle's own, compare its `decision_hash` with the outcome's `decisionHash`. | The spend list shows | What it means | What to do | | - | - | - | | No spend with this ref | The spend has not landed, or the RPC has not seen it yet. | Wait and read again. Do not send it again. | | `pending` | The payout was sent and its callback has not settled the spend yet. | Read again later. | | `paid` or `burned` | It paid. | Record it. Never pay this invoice again. | | `held` | The vault holds it for an approver. | See [Contract hold](#contract-hold). | | `failed`, `rejected` or `expired` | The vault released it. Nothing was paid. | The invoice is free again. The ref stays used, so a retry needs a new ref. | Once the chain shows the spend, the next cycle for the same invoice decides from the chain. A paid spend gives `refused_by_code` with `invoice.duplicate`, under any ref. A released spend lets the invoice pay again under a new ref. The SDK's outcome tests run both cases. ### The 48-hour guard Until the outcome is known, a cycle that writes to the same ledger refuses the same invoice by code, with `ledger.unresolved`, under any ref. Such a cycle spends no model tokens, and other invoices are not blocked. The guard ends as soon as one of these holds: - A later payment entry of the same decision records a known status. - A spend on chain carries the decision's hash. - `UNKNOWN_SETTLE_MS`, 48 hours, has passed since the payment entry's `at`, and no spend on chain carries the decision's hash. > **Warning** > > The guard reads the cycle's payment entry. An `unconfirmed` outcome with `ledgerComplete: false` has no such entry, so nothing guards its invoice: find the spend by its ref before you run any cycle for that invoice. This follows from the SDK's code; no test shows it yet. A transaction expires 86,400 blocks after it is signed, which is at most 48 hours at up to 2 seconds a block. Even if it lands later, the vault lets at most one spend of an invoice pay. The guard matches by invoice hash, because the ledger holds no ref. The refusing rule in the decision entry says when the invoice can go again. The part in parentheses appears only when the transaction hash is known: ```text an unconfirmed payment for this invoice from (tx ) may still land; this invoice can be retried after , and its outcome may appear on chain sooner ``` ## Failed Definitely not paid. `spendId` tells the two cases apart. ### Failed after the vault took the spend `spendId` is set. The vault accepted the spend, the payout failed, and the vault released the spend. `code` is the spend's status on chain, `failed` in the usual case. A release gives back the reservation, the use and the invoice hash, but never the ref. So retry the same invoice under a new ref. The SDK's outcome test runs exactly that: a failed payout, the same ref again, then the new ref `INV-1-retry`. `packages/sdk/test/probarion.outcomes.test.ts` ```ts expect(first).toMatchObject({ status: "failed", spendId: 1, code: "failed", ledgerSeq: 1 }); expect(sameRef).toMatchObject({ status: "refused_by_code", code: "invoice.duplicate" }); expect(retry).toMatchObject({ status: "paid", spendId: 2 }); ``` The same ref comes back as `refused_by_code` with `invoice.duplicate`, because the ref stays used. The new ref pays as spend 2. A common cause is a vault that holds less USDC than the amount. The 14 checks on [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md) do not read the vault's USDC balance, so the vault accepts the spend and the transfer fails. A sandbox test leaves a vault with 5 USDC, sends a 10 USDC spend, and gets back `{"spend_id":1,"status":"failed"}` with the reason `ft_transfer_failed`. For a NEAR payee on a testnet vault, the vault's owner, signed in to the console, opens its Top-up tab (`/console//topup`) and presses "Add test USDC". Any other member of the vault who opens the tab reads "Only this vault's owner can top it up." and gets no button. The button mints 25 test USDC on NEAR into the vault, with no wallet: the team's mint-only key signs. It works once a UTC day per vault, for up to 3 vaults per signed-in account a day. Without a faucet key, the tab shows this instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." For a Base or Solana payee, a withdrawal that NEAR Intents fails or refunds ends the same way, with the reason `ft_withdraw_failed` or `ft_withdraw_refunded`. The faucet does not add to a balance inside NEAR Intents. ### Failed before the vault took the spend `spendId` is null. The vault never took the spend: the node refused the signed transaction, the transaction could not be sent, or the spend receipt failed without a vault code. | `code` | What happened | `txHash` | What to do | | - | - | - | - | | `TxSendFailed` | The SDK could not build or sign the transaction, so nothing was sent. This includes an agent key the vault account does not have: the SDK reads the key before it signs, and the message says the access key does not exist. The same code comes back when a `sendTimeoutMs` function gave too little time, or threw, before the transaction was signed or sent: nothing was sent then either. | `null` | Fix what the message names, then run the cycle again. For a missing key, do what the `AccessKeyNotFound` row says. | | `NotEnoughAllowance` | The agent key's gas allowance is used up, so the node refused the transaction before it ran. | `null` | The owner removes the key and adds it again with a new allowance, as below. | | `AccessKeyNotFound`, or another [`TxRejectedError`](https://probarion.xyz/docs/build/errors.md#TxRejectedError) kind | `AccessKeyNotFound` means the node refused the spend: the agent key is not on the vault account. A missing key usually shows up as `TxSendFailed`. Under `process()`, this code appears when the key goes away between the SDK's read of it and the send. Another kind names its own cause. | `null` | Fix what the kind names. For a missing key, the owner adds it with `agent add`. If the vault still lists the key in `list_agents`, the owner first removes it with `agent remove` and then adds it, as below. | | `LackBalanceForState` | The vault would fall below the NEAR its storage needs. Each paid spend keeps about 0.0048 NEAR of storage. | `null`, or the failed transaction | Send the vault account a plain NEAR transfer, then run the cycle again. | | `NotEnoughBalance` | The vault holds less NEAR than a spend must prepay for gas, about 0.02 NEAR at 200 Tgas. | `null`, or the failed transaction | Send the vault account a plain NEAR transfer, then run the cycle again. | | `TxFailed` | The spend receipt failed without a vault code, for example "Exceeded the prepaid gas." | The failed transaction | Read the transaction to see why before you run the cycle again. | Nothing was reserved, so the invoice and the ref are still free for the next cycle. This follows from the code; no test retries one of these under the same ref yet. The vault refuses to add a key it already lists, so the owner removes the spent key first and then adds it again. The same holds when the access key is gone but `list_agents` still shows the key. A sandbox test deletes an agent's access key, sees the vault still list one agent, then removes the agent, adds it again and pays with it. Its `remove_agent` transaction shows one failed receipt, `DeleteKeyDoesNotExist`, and the vault still drops the key from its list. The vault also keeps listing a key whose own key-adding receipt failed after `add_agent`, for example with `LackBalanceForState`, because the list changes with the call and the key is added in a receipt of its own. This follows from the contract's code; no test shows it for an agent key yet. A mandate names the public key itself, so re-adding the same key keeps the mandates bound to it usable. This follows from the contract's code; no test shows it for a mandate created before the removal. ```bash # Illustrative values: your vault, the agent's public key and your owner account. node packages/cli/dist/bin.js --network testnet agent remove --vault acme.vaults.probarion.testnet \ --pk ed25519:... --sign-as owner.testnet --out-dir probarion-tx node packages/cli/dist/bin.js --network testnet agent add --vault acme.vaults.probarion.testnet \ --pk ed25519:... --allowance 1 --sign-as owner.testnet --out-dir probarion-tx ``` > **Owner signs** > > The owner signs `remove_agent` and `add_agent` from their own wallet, each with exactly 1 yoctoNEAR attached. The CLI prints each transaction and never signs it. `--allowance` is the key's gas allowance in NEAR, 1 by default. > > _The owner signs this transaction. Probarion never signs it._ ## Refusals Nothing was paid. A refusal by code sends nothing, and a refusal by the contract reverts every change, so neither one uses up this cycle's ref. ### Refused by code `code` is the first rule id that refused. `decide()` records the rules that mirror the vault's checks in the contract's own order, so the first refusing mirrored rule is the code `spend` would have panicked with. The off-chain rules sit between them. The cycle writes its decision entry, with the verdict `refuse`, and nothing reaches the vault. | Rule | Effect | When | Vault code it mirrors | | - | - | - | - | | `contract.check_failed` | Refuses | The invoice hash or the ref has the wrong shape. `process()` and `requestSpend()` throw `InvalidInput` for these first. The SDK also writes this rule in a payment entry after a `check_spend` refusal, a `spend` panic, and any failure the vault did not release (a send that did not run, or a spend receipt that failed without a vault code). | [`InvalidInput`](https://probarion.xyz/docs/build/errors.md#InvalidInput) | | `vault.frozen` | Refuses | The owner has frozen the vault. | [`Frozen`](https://probarion.xyz/docs/build/errors.md#Frozen) | | `mandate.none` | Refuses | No mandate matches the invoice's purpose, or the mandate the model proposed does not exist. | [`NoMandate`](https://probarion.xyz/docs/build/errors.md#NoMandate) | | `mandate.inactive` | Refuses | The mandate is revoked. | [`MandateInactive`](https://probarion.xyz/docs/build/errors.md#MandateInactive) | | `mandate.expired` | Refuses | The mandate has expired, or the clock or the expiry cannot be read. | [`MandateExpired`](https://probarion.xyz/docs/build/errors.md#MandateExpired) | | `mandate.agent_mismatch` | Refuses | The mandate is bound to another agent key. | [`NotMandateAgent`](https://probarion.xyz/docs/build/errors.md#NotMandateAgent) | | `invoice.po_mismatch` | Refuses | The invoice has no `purpose_hash`, its purpose differs from the mandate's, or its purpose maps to another mandate. | None: off-chain only | | `mandate.payee_mismatch` | Refuses | The invoice's `payee_hint`, or the model's, contradicts the mandate's payee. A payee name never contradicts. | None: off-chain only | | `invoice.amount_invalid` | Refuses | The amount is not a positive integer in the token's smallest units, or the model's amount differs from the invoice's. | [`ZeroAmount`](https://probarion.xyz/docs/build/errors.md#ZeroAmount), in part | | `mandate.over_per_payment` | Refuses | The amount is above the mandate's `max_per_payment`. | [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment) | | `mandate.uses_exhausted` | Refuses | The mandate has no use left. | [`UsesExhausted`](https://probarion.xyz/docs/build/errors.md#UsesExhausted) | | `mandate.over_total` | Refuses | Spent plus reserved plus this amount would pass `max_total`. | [`OverMandateTotal`](https://probarion.xyz/docs/build/errors.md#OverMandateTotal) | | `invoice.duplicate` | Refuses | The invoice is held, pending, paid or burned on the vault, or a spend has already used the ref. | [`DuplicateInvoice`](https://probarion.xyz/docs/build/errors.md#DuplicateInvoice), [`DuplicateRef`](https://probarion.xyz/docs/build/errors.md#DuplicateRef) | | `vault.daily_backstop` | Refuses | The vault's total for the UTC day plus this amount would pass the daily backstop. | [`OverDailyBackstop`](https://probarion.xyz/docs/build/errors.md#OverDailyBackstop) | | `intents.balance_low` | Holds | See [Code hold](#code-hold). | None | | `model.refused` | Passes, holds or refuses | The model proposed `pay`, `hold` or `refuse`. An action it does not recognise refuses. | None | | `model.low_confidence` | Holds | The model's confidence is below `confidenceFloor`. | None | | `model.attestation_invalid` | Passes, noted only | A proposal passed to `requestSpend()` had no evidence, or its evidence failed the checks. The proposal was ignored, and the decision is rules-only. `process()` never records this rule. | None | | `model.unavailable` | Passes, noted only | The model failed, or in `process()` its answer failed the attestation checks at this ledger position. The proposal was dropped, and the decision is rules-only. | None | | `ledger.unresolved` | Refuses | An earlier payment of this invoice is unconfirmed and has not settled. The SDK adds it, not `decide()`. See [The 48-hour guard](#the-48-hour-guard). | None | | `custom.*` | Passes, holds or refuses | One of your rules returned `pass`, `hold` or `refuse`. A custom rule can only tighten the verdict. `probarion.rules.add()` throws `ConfigError` for an id that does not start with `custom.` or is only `custom.`, and for a rule with no `check()`. Once added, a rule that returns an outcome other than the three, or that throws, refuses. | None | `invoice.duplicate` covers two cases that need different actions, and only the rule's detail in the decision entry tells them apart. When the detail says the invoice "was already paid or is pending", a spend of that invoice is held, pending, paid or burned on the vault: do not pay it again, and a held one waits for its approver. When the detail says the ref "was already used", only the ref is spent: run the cycle again under a new ref. `PayeeMismatch` is not a vault code: a spend names no payee, so only the off-chain rule `mandate.payee_mismatch` can refuse a contradicting invoice. [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md) explains it. ### Refused by the contract `code` is the bare vault code, such as `OverPerPayment`, never `PROBARION:OverPerPayment`. It comes back in one of two ways: - **After the `check_spend` dry run.** `txHash` is null, because nothing was sent. The payment entry records the contract status `check_failed` and the rule `contract.check_failed`. - **After a `spend` panic.** `txHash` is the failed transaction. The vault refused the spend and reverted every change. The payment entry records the contract status `refused` and the rule `contract.check_failed`. In the SDK's tests the vault is frozen between the dry run and the send, or two writers with different decisions race for the same invoice and the loser gets `DuplicateInvoice` because the other writer's spend holds it. The code rules cannot see everything. `decide()` has no list of the vault's agent keys, so [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent) has no rule, and the chain can change between the SDK's read and the send. Every vault code has its own row in [Errors](https://probarion.xyz/docs/build/errors.md), anchored by its name, for example [`/docs/build/errors#OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment). ## Paid, burned and settled - **`paid`.** The vault sent USDC to the mandate's NEAR payee with `ft_transfer`, and its callback settled the spend. Final. - **`burned`.** The mandate pays a Base or Solana address. The vault called `ft_withdraw` on NEAR Intents with the memo `WITHDRAW_TO:` followed by the payee's address, and the bridge releases the funds on the other chain. The vault counts any withdrawal result other than an error or "0" as burned, because value may have left the vault. The payment entry records a settlement with the chain, a `tx_hash` of null and the spend transaction as `burn_tx_hash`, which is null too when the spend was found by its ref. - **`settled`.** The type member for that later release. `process()` never returns it. A burn is final for the vault, and its status stays `burned`. Probarion does not watch the release on the other chain yet, so nothing in the vault or the ledger changes when the funds arrive, and polling `probarion.spends.get(spendId)` for a change never ends. Never send the payment again, under the same ref or a new one. If the payee reports nothing after a while, tell the vault owner, with the destination and the spend transaction. `txHash` is null on a `paid`, `burned` or `failed` outcome with a `spendId`, or on a contract hold, in one case: the vault answered `DuplicateRef` or `DuplicateInvoice` to a re-signed copy, and the SDK found this decision's own spend by its ref. A re-signed copy comes from the SDK's sender after `InvalidNonce`: it signs the same spend again only once the node says it does not know the first copy. If that first copy landed anyway, the vault answers the new copy with a duplicate code. The transaction that landed the spend is then unknown to the SDK, and the payment entry records the answered transaction as `contract.tx_hash`. The SDK's outcome tests pin this for `paid`, `burned` and `failed`. For a contract hold, it follows from the SDK's code; no test shows it yet. On NEAR testnet, `intents-stub` stands in for `intents.near`, so a `burned` payout does not leave NEAR. The SDK takes either network. On mainnet one small vault, `v0.probarion.near`, has made one NEAR payment. No Base payout has run on mainnet, so no spend there is `burned`: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). ## Ledger fields - **`ledgerSeq`.** The seq of the last entry the cycle wrote. It is the payment entry's seq when one was written, and the decision entry's when none follows: a refusal by code, a code hold, or a payment entry that could not be written. - **`decisionHash`.** The decision entry's body hash. The spend carries it on chain, which is how the verifier ties a spend to its decision. - **`anchorTxHash`.** The anchor written after the cycle. Null when the anchor was not needed, when the anchor failed, or when the vault's last anchor covers more entries than this ledger holds (the SDK logs a ledger fork warning, `LEDGER_FORK_RISK`, and does not anchor). An outcome test pins that last case. A failed anchor is only logged: the next cycle anchors the whole tail. - **`ledgerComplete: false`.** The payment entry could not be written. The outcome still stands, and the chain keeps the spend and its `decision_hash`, but the local ledger lacks the outcome entry. A complete cycle carries no `ledgerComplete` field at all. For a burned spend, [check h](https://probarion.xyz/docs/trust/verifier-checks.md#check-h) may then report the burn as unproven, with the reason `not_recorded`. [The ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md) explains the entries and the anchors. ## Duplicate reports Two identical calls can both report the same `spendId` as paid for one payment. In the SDK's test, two writers with identical decisions race: both outcomes are `paid`, with the same `decisionHash` and the same `spendId`, and the chain holds one spend. Count payments by `spendId`, never by successful outcomes. The same tests cover the other two cases. In one instance, a second identical call is `refused_by_code` with `invoice.duplicate`. Across two instances with different decisions, the loser is `refused_by_contract` with `DuplicateInvoice`. Run one writer per vault. ## When process() throws Some problems end the call with an error instead of an outcome. Each class has its row in the SDK table on [Errors](https://probarion.xyz/docs/build/errors.md), and [Thrown or returned](https://probarion.xyz/docs/build/errors.md#thrown-or-returned) lists when `process()` throws. Here is what each one means for the cycle: - [`ProbarionError`](https://probarion.xyz/docs/build/errors.md#ProbarionError) with the code `InvalidInput`, which also covers `facts` whose `invoice_hash` is not the hash of the invoice text, or whose `po_ref` is not copied from it word for word: thrown before any write. - [`ConfigError`](https://probarion.xyz/docs/build/errors.md#ConfigError): thrown before any write for an instance with no agent key or no ledger key, an agent key that does not sign as the vault account, a vault with no ledger key yet (`set_ledger_key`), or a ledger key that is not the vault's. It is also thrown at spend time when the agent's sender refuses the call as not set up. In the SDK's tests, a ledger key that is not the vault's leaves the ledger empty and sends nothing, and a `ConfigError` at spend time leaves the decision entry written, no payment entry, nothing anchored and no spend sent. - [`RpcError`](https://probarion.xyz/docs/build/errors.md#RpcError) or [`ViewError`](https://probarion.xyz/docs/build/errors.md#ViewError): a read of the vault failed, and nothing was sent. A failed read before the decision comes before any write. A failed `check_spend` dry run comes after the decision entry is written. Either way the ref is still free, so run the cycle again. This follows from the SDK's code; no test shows it yet. - [`LedgerConflictError`](https://probarion.xyz/docs/build/errors.md#LedgerConflictError): another writer moved the ledger first. No entry was written and no spend was sent. Run the cycle again. - [`LedgerError`](https://probarion.xyz/docs/build/errors.md#LedgerError): the ledger store failed, and no spend was sent. When a store that does not compare-and-append puts the decision at another position than the one it was attested for (`LEDGER_FORK_RISK`), that entry is written. - [`ProbarionError`](https://probarion.xyz/docs/build/errors.md#ProbarionError) with the code `Internal`: a failed internal consistency check, thrown before any write. ## Related pages - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle inside your agent. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): both kinds of hold, and what a release gives back. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks a spend meets inside the vault. - [Errors](https://probarion.xyz/docs/build/errors.md): every vault code and SDK error. - [Verifier checks](https://probarion.xyz/docs/trust/verifier-checks.md): how `verify` ties spends, decisions and burns together. --- # Errors > Every vault panic code with its meaning and next step, the SDK error classes, the transaction failure codes and the hosted app's HTTP errors. Look up a Probarion error by its code: what it means, what raised it, whether a retry helps and what to do next. Codes come from the vault contract, the SDK and the hosted app's HTTP routes. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-07 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/errors Probarion reports a problem in one of these layers, and each layer has its own codes: - **The vault contract** refuses a call with a panic whose message is exactly `PROBARION:`. The whole call reverts. - **The SDK** throws errors that extend `ProbarionError` and carry `.code`. What the vault or the node answered about a spend comes back in the outcome's `code` instead. - **The hosted app's HTTP routes** answer with an HTTP status and a JSON body, `{ error, detail? }`. Every vault code and SDK error class has its own anchor, so you can link it, for example `/docs/build/errors#NoMandate`. Each HTTP row is anchored by its status and code, for example `/docs/build/errors#http-404-not_found`. The transaction failure codes and the unknown outcome codes are linked through their sections' anchors, [Transaction failures](https://probarion.xyz/docs/build/errors.md#transaction-failures) and [Unknown outcomes](https://probarion.xyz/docs/build/errors.md#unknown-outcomes). SDK rule ids such as `mandate.over_per_payment` are not errors. They are the reasons a decision gives, and they are on [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md). ## Where a code shows up The contract builds every refusal message in one place: `contracts/vault/src/errors.rs` ```rust pub fn message(self) -> String { format!("PROBARION:{}", self.as_str()) } ``` On NEAR, a panic undoes every state change the call made. The failed transaction stays on chain, and the code reaches you in one of these forms: | Where | What you see | | - | - | | An explorer, near-cli-rs or an RPC transaction status | `Smart contract panicked: PROBARION:` | | `process()` in the SDK | The status `refused_by_contract`, with the bare code in `code`, such as `DuplicateRef` when another spend took the ref after the SDK read the vault. `txHash` is null when the `check_spend` dry run refused, and the failed transaction's hash when `spend` itself panicked. | | `checkSpend()` in the SDK | `ok: false` and the bare code, with no `PROBARION:` prefix. | | A `ViewError` | `probarionCode`, when the view call panicked with a vault code. | | A `ContractError` | `vaultCode`, and the same value in `.code`, when a transaction the SDK sent was refused, for example by `VaultClient.anchor()`. | | `check` in the CLI | `REFUSED: PROBARION:` on the first line, and exit code 1. | The README's evaluation records this `checkSpend()` answer for 6 USDC on a mandate that allows at most 5 USDC per payment: ```text 6 USDC: { ok: false, code: 'OverPerPayment', would_hold: false } ``` > **A message without PROBARION: is not a vault code** > > A failure message without the `PROBARION:` prefix did not come from the vault's own checks. It came from the NEAR node, from near-sdk, or from a contract the vault called, such as the USDC token or NEAR Intents. For a spend it sent, the SDK names those failures with its own codes, under [Transaction failures](https://probarion.xyz/docs/build/errors.md#transaction-failures), and the near-sdk messages you may meet are under [Messages that are not Probarion codes](https://probarion.xyz/docs/build/errors.md#messages-that-are-not-probarion-codes). ### Read a code in your own code The SDK exports these helpers for vault codes and for the out-of-NEAR codes: - `extractProbarionCode(value)` reads the first `PROBARION:` in a string, an error or a nested RPC failure. It returns the bare code when it is a vault code, else null, even when a vault code comes later. - `isVaultErrorCode(code)` is true only for a code in `VAULT_ERROR_CODES`. - `normalizeVaultCode(code)` strips a `PROBARION:` prefix and checks nothing else, so test its result with `isVaultErrorCode`. - `isOutOfNearCode(code)` is true for `LackBalanceForState` and `NotEnoughBalance`. `codes.mjs` ```js import { extractProbarionCode, isOutOfNearCode, isVaultErrorCode, normalizeVaultCode, } from "./packages/sdk/dist/index.js"; console.log(extractProbarionCode("Smart contract panicked: PROBARION:NoMandate")); // NoMandate console.log(extractProbarionCode("PROBARION:PayeeMismatch")); // null: not a vault code console.log(normalizeVaultCode("PROBARION:Frozen")); // Frozen console.log(isVaultErrorCode("LackBalanceForState")); // false console.log(isOutOfNearCode("LackBalanceForState")); // true ``` The comments give what `packages/sdk/src/errors.ts` and `packages/sdk/src/tx.ts` return for these inputs. Save the file in the root of a clone built as [Install](https://probarion.xyz/docs/build/install.md) shows, and run `node codes.mjs` there. ### The CLI `check` asks the vault's free `check_spend` view and prints its answer on the first line. A refusal looks like this, and the command exits 1: ```text REFUSED: PROBARION:OverPerPayment ``` Any other failure of the CLI prints `error: ` on stderr and exits 1. `check` and `verify` exit 64 for a usage error, so a bad flag never looks like a refusal or a failed check. The CLI signs no owner or approver call. It checks the input, writes the arguments to a file and prints the near-cli-rs command. When the vault refuses that call, near-cli-rs shows `Smart contract panicked: PROBARION:`. ## Vault error codes The table lists every code in `VAULT_ERROR_CODES` (`packages/core/src/types.ts`), in the contract's own order. The factory contract writes its refusals in the same format, with the same names. - **Raised by** names the methods that refuse with the code. "check n" is the n-th of the 14 spend checks that `spend` and `check_spend` run, in the order on [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md). When two checks would fail, the first one is the answer. - **Retry helps** is Yes when the same call, sent again unchanged after a wait, is expected to pass. No means something has to change first, such as the request, the signer or a mandate. | Code | Meaning | Raised by | Retry helps | What to do | | - | - | - | - | - | | `NoMandate` | The vault has no mandate with this id; the `check_spend` dry run reports it too. | `spend` (check 4, and its payout), `revoke_mandate` | No | Use a mandate id from the vault's mandate list; ids start at 1. | | `MandateInactive` | The mandate has been revoked, so it takes no new spend or approval; the `check_spend` dry run reports it too. | `spend` (check 5), `approve`, `revoke_mandate` (on a revoked mandate) | No | Create a new mandate if this payee should still be paid (the owner signs). | | `MandateExpired` | The block time is at or past the mandate's `expires_at_ns`; the `check_spend` dry run reports it too. | `spend` (check 6) | No | Create a new mandate with a later expiry (the owner signs). | | `NotMandateAgent` | The signing key is one of the vault's agent keys, but not the `agent_pk` this mandate names; the `check_spend` dry run reports it too. | `spend` (check 7) | No | Sign the spend with the agent key the mandate names. | | `ZeroAmount` | The amount is 0; the `check_spend` dry run reports it too. | `spend` (check 8) | No | Send an amount above zero, written in the token's base units. | | `OverPerPayment` | The amount is above the mandate's `max_per_payment`; the `check_spend` dry run reports it too. | `spend` (check 9) | No | Check the invoice amount, and if it is right, create a mandate with a higher cap (the owner signs). | | `UsesExhausted` | The mandate has been used `max_uses` times (a failed, rejected or expired spend gives its use back); the `check_spend` dry run reports it too. | `spend` (check 10) | No | Wait for a held or pending spend of this mandate to be released, or create a new mandate (the owner signs). | | `OverMandateTotal` | The mandate's spent and reserved amounts plus this amount would pass `max_total` (a failed, rejected or expired spend gives its reservation back); the `check_spend` dry run reports it too. | `spend` (check 11) | No | Wait for a held or pending spend of this mandate to be released, or create a new mandate with room for this payment (the owner signs). | | `DuplicateInvoice` | This `invoice_hash` belongs to another spend that is held, pending, paid or burned; the `check_spend` dry run reports it too. | `spend` (check 12) | No | Find the spend that holds this invoice in the vault's spend list, and do not pay the invoice again. | | `DuplicateRef` | The vault has already accepted a spend with this `ref`, and a ref stays used even when that spend later fails, is rejected or expires; the `check_spend` dry run reports it too. | `spend` (check 13) | No | Find the spend with this `ref` in the vault's spend list, and do not send the payment under a new `ref` until you know its status. | | `OverDailyBackstop` | The vault's total for this UTC day, plus this amount, would pass the daily backstop; the `check_spend` dry run reports it too. | `spend` (check 14) | Yes | Wait for the next UTC day, since the backstop is fixed when the vault is created and no method changes it. | | `Frozen` | The owner has frozen the vault, so it takes no spend and no approval; the `check_spend` dry run reports it too. | `spend` (check 3), `approve` | Yes | Ask the owner to call `unfreeze` with exactly 1 yoctoNEAR attached (the owner signs). | | `NotAgent` | `spend` was called from an account other than the vault, or signed with a key that is not one of the vault's agent keys; the `check_spend` dry run reports the second case. | `spend` (its caller check, and check 1) | No | Sign the spend on the vault account with one of its agent keys. | | `NotOwner` | The caller is not the vault's owner account. | `create_mandate`, `revoke_mandate`, `add_agent`, `remove_agent`, `set_ledger_key`, `freeze`, `unfreeze`, `withdraw`, `withdraw_intents` | No | Send the call from the owner account (the owner signs). | | `NotApprover` | The caller is not one of the vault's approvers. | `approve`, `reject` | No | Send the call from one of the vault's approver accounts (the approver signs). | | `NotLedgerKey` | `anchor` was not signed on the vault account with the vault's ledger key. | `anchor` | No | Sign `anchor` on the vault account with the ledger key that `set_ledger_key` set. | | `NotHeld` | There is no spend with this id, or the spend is not held. | `approve`, `reject`, `expire_held` | No | Read the spend with `get_spend` to see its current status. | | `HoldExpired` | `approve` came after the spend's `held_until_ns`. | `approve` | No | Release the spend with `expire_held`, which anyone may call, and submit the payment again with a new `ref`. | | `HoldNotExpired` | `expire_held` came at or before the spend's `held_until_ns`. | `expire_held` | Yes | Wait until `held_until_ns` has passed, then call `expire_held` again. | | `BadSeq` | The anchor's `seq` is not the number of anchors so far plus 1. | `anchor` | No | Read the last anchor with `list_anchors` and anchor with the next seq, as the SDK does once by itself, and keep one ledger writer per vault. | | `AssetNotAllowed` | The payee's asset is not in the vault's `allowed_assets`, or its prefix does not match the payee's chain; `check_spend` does not run this check. | `create_mandate`, `spend` and `approve` (at payout) | No | Create the mandate with an asset from the vault's `allowed_assets` that matches the payee's chain (the owner signs). | | `InvalidInput` | An argument is malformed or not allowed, for example a hash that is not 64 lowercase hex characters, a `ref` that is not 1 to 64 bytes, mandate limits that do not fit together, a zero amount or allowance, or a key the vault already holds (or, for `remove_agent`, does not hold); the `check_spend` dry run reports the spend case too. | `spend` (check 2), `create_mandate`, `add_agent`, `remove_agent`, `set_ledger_key`, `withdraw`, `withdraw_intents`, `anchor`, `new`, the factory's `create_vault` | No | Fix the argument the method refused and send the call again. | | `InsufficientDeposit` | Less NEAR was attached than the call needs: for `create_vault`, the factory's `min_deposit`, plus the storage of the vault wasm when the current version is embedded; for `create_mandate`, the mandate's storage plus, for a NEAR payee, the payee's USDC storage registration. | `create_mandate`, the factory's `create_vault` | No | For `create_vault`, attach at least the `min_deposit` that the factory's `get_config` returns, plus the wasm's storage for an embedded version; for `create_mandate`, attach more NEAR, since the vault refunds the excess (the owner signs). | | `RequiresOneYocto` | The call did not attach exactly 1 yoctoNEAR, which only a full-access key can attach. | `unfreeze`, `withdraw`, `withdraw_intents`, `revoke_mandate`, `add_agent`, `remove_agent`, `set_ledger_key`, `approve`, `reject` | No | Sign with a full-access key and attach exactly 1 yoctoNEAR. | > **Owner signs** > > Several next actions on this page are owner transactions: `create_mandate`, `unfreeze`, and `remove_agent` then `add_agent` for an agent key. The CLI prints them and signs nothing, and the owner signs them from their own wallet. `freeze` takes no deposit. Most other owner methods need exactly 1 yoctoNEAR. `create_mandate` takes a storage deposit and refunds the excess. > > _The owner signs this transaction. Probarion never signs it._ > **Approver signs** > > An approver signs `approve` and `reject` from their own wallet, with exactly 1 yoctoNEAR attached. The CLI's `spend approve` and `spend reject` print the transaction and sign nothing. > > _An approver signs this transaction. Probarion never signs it._ With `process()`, many of these codes never reach the vault. The SDK's `decide()` mirrors most spend checks and refuses first, as `refused_by_code` with a rule id. Then `process()` asks `check_spend` before it sends anything. [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) lists the seven statuses `process()` returns. The order, the limits and the counters behind each check are on [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md) and [Mandates](https://probarion.xyz/docs/concepts/mandates.md). ### When the vault answers DuplicateRef or DuplicateInvoice A ref is used up once the vault accepts a spend, held or paid, and it stays used even if the payout fails. A refused spend reverts every change, so its ref stays free. An `invoice_hash` is taken only while a spend on it is held, pending, paid or burned. `process()` refuses a used ref and a taken invoice by code first, with `invoice.duplicate`, so the vault answers [`DuplicateRef`](https://probarion.xyz/docs/build/errors.md#DuplicateRef) or [`DuplicateInvoice`](https://probarion.xyz/docs/build/errors.md#DuplicateInvoice) only when a spend with that ref or invoice landed after the SDK read the vault. When the `check_spend` dry run answers the duplicate code, `process()` returns `refused_by_contract` with that code and `txHash` null at once, because nothing of this cycle was sent. When `spend` itself answers it, the spend that holds the ref or the invoice can be this cycle's own first copy, whose answer was lost. So for that answer the SDK does not report the code at once. It lists the vault's spends and looks for this decision's own spend, with the same ref, decision hash, invoice, mandate and amount: | What the SDK finds | The outcome | | - | - | | This decision's spend, paid, burned or held | That status, with the spend's id. | | This decision's spend, still pending | `unconfirmed`, with the code `pending`. | | This decision's spend, released as failed, rejected or expired | `failed`, with that status as the code. | | Another decision's spend holds the ref, or holds the invoice live | `refused_by_contract`, with the duplicate code. | | No spend holds it, or the list could not be read | `unconfirmed`, with the duplicate code. It may have paid. | A spend that may have paid is never reported as refused or failed. To look a spend up yourself, read the vault's spend list: `spends.get` takes a spend id, not a ref, and the contract has no lookup by ref. [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md) shows the lookup. ## SDK errors Every error class the SDK exports extends `ProbarionError`, so one `instanceof ProbarionError` catches them all, and `.code` names the case. A subclass can share its parent's `.code`, so test the class with `instanceof` when the difference matters. | Class | `.code` | When | Extra fields | | - | - | - | - | | `ProbarionError` (extends Error) | `InvalidInput` | The base class of every SDK error. The SDK also throws it with its own codes: `InvalidInput` (a malformed argument, refused before anything is read, sent or written), `TxSendFailed` (nothing was sent: the transaction could not be built or signed, or the time left the caller gave was too short, or could not be read, before it was signed or sent), `TxFailed` (the transaction result could not be read) and `Internal` (a failed internal consistency check, which a caller should report). | `code` | | `RpcError` (extends ProbarionError) | `RpcError` | A transport failure or a JSON-RPC error answer: every RPC URL failed, a node returned an error, or the time for the job ran out. | `attempts`, `rpcError` | | `RpcMalformedError` (extends RpcError) | `RpcError` | A node answered, but not in a shape the client can read. It is never a transport failure. | None | | `ViewError` (extends RpcError) | `RpcError` | A view call failed or returned an answer that is not the expected value; `probarionCode` is the vault code when the view panicked with one. | `contractId`, `methodName`, `probarionCode` | | `ContractError` (extends ProbarionError) | `TxFailed` | A transaction the SDK sent failed on chain, for example an `anchor` the vault refused (a refused spend is a spend outcome, not an error). `.code` is the vault code when there is one, the same value as `vaultCode`, and `TxFailed` otherwise. | `vaultCode`, `txHash` | | `TxOutcomeUnknownError` (extends ProbarionError) | `TxOutcomeUnknown` | The transaction was signed and sent, but the node's answer was lost, so it may or may not have run. Record `txHash`, look the outcome up, and do not send the same payment again. | `txHash`, `signerId` | | `TxRejectedError` (extends ProbarionError) | `TxRejected` | The node refused the signed transaction before running it, so nothing changed. `kind` names the refusal, for example `NotEnoughAllowance` or `AccessKeyNotFound`. | `kind` | | `ConfigError` (extends ProbarionError) | `ConfigError` | An option or a key the SDK needs is missing or malformed, or a client method got an argument it cannot send. | None | | `NearAiError` (extends ProbarionError) | `NearAiError` | The model step failed: a missing API key, an HTTP error, a malformed answer, a missing attestation or no time left. | None | | `NearAiResponseTooLargeError` (extends NearAiError) | `NearAiError` | The model's answer is larger than 64 KiB, so it is not used and the decision is rules-only. | `limitBytes` | | `LedgerError` (extends ProbarionError) | `LedgerError` | The ledger store failed: an append onto another vault's ledger or onto a broken last entry, a line that is not valid UTF-8 or JSON, a lock it could not take, or a decision that landed at the wrong position (`LEDGER_FORK_RISK`). | None | | `LedgerConflictError` (extends LedgerError) | `LedgerError` | Another writer moved the ledger's head since this append expected it, so nothing was written and no spend was sent. | `expected`, `actual` | ### Thrown or returned `process()` returns an outcome, not an error, for everything the vault or the node answers about the spend: a refusal, a failed transaction and an unknown outcome are statuses. It throws when something is wrong before a spend can be sent: - `ConfigError`: the instance has no agent key or no ledger key, the agent key does not sign as the vault account, or the ledger key is not the vault's. It is also thrown when the agent's sender refuses the call as not set up: the decision entry is then written, and nothing is sent. The constructor throws it too for a model with no API key. Pass `model: null` to decide by code only. - `ProbarionError` with the code `InvalidInput`: an input it cannot use, for example a `facts.invoice_hash` that is not `hashInvoice(text)`, or a `facts.po_ref` that is neither null nor a verbatim part of the text. Nothing is written. - `RpcError` or `ViewError`: a read of the vault failed. - `LedgerConflictError`: another writer moved the ledger since this decision was made. No entry was written and no spend was sent, and the SDK never retries it by itself. - `LedgerError`: the ledger store failed. - `ProbarionError` with the code `Internal`: a failed internal consistency check, which you should report. Nothing is written and nothing is sent. Once `spend` is sent, `process()` does not throw: every answer becomes an outcome. That is inferred: it follows from the code, and no test shows it yet. These failures are not thrown at all: - A model step that fails is logged, and the cycle decides with the rules only. - A failed anchor is logged, and the outcome's `anchorTxHash` is null. The next cycle anchors the whole tail. - A payment entry that could not be written leaves the outcome as it is, with `ledgerComplete: false`. Called directly, `VaultClient.spend()` throws where `process()` returns an outcome: a `TxRejectedError` when the node refused the signed transaction for any reason except the vault's NEAR balance, a `ProbarionError` with the code `TxSendFailed` when nothing was sent, and a [`TxOutcomeUnknownError`](https://probarion.xyz/docs/build/errors.md#TxOutcomeUnknownError) when the outcome is not known. A vault refusal, and a node refusal for `LackBalanceForState` or `NotEnoughBalance`, come back as a value, with `ok: false` and the code. ## Transaction failures These codes are not vault codes. `process()` returns them in the outcome's `code`, with the status `failed`, when the spend did not run or its own receipt failed: | Code | What happened | What to do | | - | - | - | | `TxSendFailed` | The SDK could not build or sign the transaction, so nothing was sent. This includes an agent key the vault account does not have: the SDK reads the key before it signs, and the message says the access key does not exist. The same code comes back when a `sendTimeoutMs` function gave too little time, or threw, before the transaction was signed or sent: nothing was sent then either. | Fix what the message names, then run the cycle again. Sending again is safe. For a missing key, do what the `AccessKeyNotFound` row says. | | `NotEnoughAllowance` | The node refused the signed spend before it ran: the agent key's allowance is used up. | The owner removes the key and adds it again with a new allowance: `remove_agent`, then `add_agent`. | | `AccessKeyNotFound` | The node refused it: the agent key is not on the vault account. A missing key usually shows up as `TxSendFailed`. This code appears when the key goes away between the SDK's read of it and the send. | Have the owner add the key with `add_agent`. If the vault still lists it in `list_agents`, the owner first removes it with `remove_agent`, then adds it. | | `InvalidNonce` | The node kept refusing the key's nonce, through several fresh signatures. | Look for another process that signs with the same key, then run the cycle again. | | Another refusal kind | The node refused the transaction for another reason, which the code names. Nothing ran. | Read the message, fix the cause, then run the cycle again. | | `LackBalanceForState` | The chain refused the call: the vault account would fall below the NEAR its storage needs. | Top the vault up with a plain NEAR transfer. The SDK's message says each paid spend keeps about 0.0048 NEAR of storage. | | `NotEnoughBalance` | The vault holds less NEAR than the spend must prepay for gas. | Top the vault up with a plain NEAR transfer. The SDK's message puts the gas at about 0.02 NEAR for 200 Tgas. | | `TxFailed` | The spend's own receipt failed without a vault code, for example with `Exceeded the prepaid gas.` | Read the message, fix the cause, then run the cycle again. | The node refusals are the kinds of a `TxRejectedError`. In every row `spendId` is null: the vault did not accept the spend. For `TxSendFailed` and a node refusal nothing ran at all, so the ref is still free. For the out-of-NEAR codes and `TxFailed`, the chain refused the call or the spend's own receipt failed, and a failed receipt reverts like a refusal, so the ref stays free there too. That is inferred: it follows from the code, and no test shows it yet. How a missing agent key shows up is inferred too. The SDK reads the key from the vault account before it signs, so a key the account does not have fails there, as `TxSendFailed`, and `AccessKeyNotFound` needs the key to go away after that read. With `process()`, a key the vault does not list never gets that far: it is refused by code when no mandate names it, and else the `check_spend` dry run answers [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent). The missing key `process()` can meet is one the vault still lists, for example after its key-adding receipt failed. A `failed` outcome can also carry a spend status as its code, with `spendId` set. Then the vault accepted the spend and released it: the payout failed, or the spend the SDK found by ref was rejected or expired. Its ref is used up, so the same invoice needs a new ref. ### Unknown outcomes These codes come with the status `unconfirmed`. The spend was sent, or may have been, and may have paid: | Code | What is not known | | - | - | | `TxOutcomeUnknown` | The spend was signed and sent, but the node's answer was lost, or a sender the SDK does not know failed in a way that may have sent it. | | `pending` | The vault accepted the spend, and its payout is still pending. | | `callback_failed` | The spend's receipt ran and a later receipt failed, or the result does not show which one failed. The payout may have gone out. | | `unreadable_value` | The transaction succeeded, but its return value is not a spend result. | | `DuplicateRef`, `DuplicateInvoice` | The vault answered with a duplicate code, and no spend holding the ref or the invoice was found, or the spend list could not be read. | > **Never send an unconfirmed spend again** > > Do not send it under a new ref either. Find the spend by its ref in the vault's spend list, or wait. Until the chain shows the spend, or for 48 hours (`UNKNOWN_SETTLE_MS`), the SDK refuses the same invoice by code with `ledger.unresolved`. ## HTTP API errors The hosted app's routes answer an error with a JSON body that is never cached: - the headers `content-type: application/json; charset=utf-8` and `cache-control: no-store`; - the body `{ "error": "" }`, with a `"detail"` string when the route has more to say. A few rows add fields, which the table names. For example, a request the rate limiter refuses gets this body: ```json { "error": "rate_limited", "detail": "Try again in 12 s" } ``` A `429` from the shared rate limiter also sends a `Retry-After` header with the same number of seconds as its detail, `Try again in N s`. The faucet's daily limits answer `429` with a sentence of their own instead, and a `Retry-After` that runs to midnight UTC. ### Who calls these routes The routes belong to the hosted app: its console, sign-in, top-up and proof files, among others. They are not an agent API. - The console routes need a signed-in wallet session. - The sign-in, sign-out, console, top-up and Canary routes that a page posts to check that the request comes from a page at the same address, and answer `403 bad_origin` otherwise. The alert links' confirm and stop routes do not check it: the code or token from the alert email is the credential. - In the console routes, a vault that does not exist gets the same `403 forbidden` as one you are not a member of, so a route never confirms a vault id. The faucet answers `404 not_found` instead, for these, for a member who is not the owner and for a vault registered on the other network. An agent pays from its own process, through the SDK. The CLI sends no spend: `check` only dry-runs one. See [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md). On a testnet vault, test USDC comes from the faucet. Signed in as the vault's owner, open its Top-up tab (`/console//topup`) and press the "Add test USDC" button. It mints 25 test USDC into the vault, and your wallet signs nothing: the team's mint-only key signs the mint. It works once a UTC day per vault, for up to 3 vaults per signed-in account a day. When the server has no faucet key, the tab shows instead: "Need test USDC? Send @probarionhq a direct message on X with your vault's name." The rows that name Top-up API (faucet) cover the faucet's own answers, such as `not_owner` and `mint_failed`. The faucet also answers [`503 not_configured`](https://probarion.xyz/docs/build/errors.md#http-503-not_configured) when the server's faucet key does not parse or is not a mint-only key. | Status | Code | Returned by | Meaning | What to do | | - | - | - | - | - | | 400 | `bad_code_format` | Extension API (pairing) | The pairing code is not 8 letters and digits. | Enter the code exactly as the Browser extension page shows it, for example `K7QM-4XRA`. | | 400 | `empty` | Console invoices API, Canary attempt API | There is no text to work on: no text could be read from the invoice (a scanned PDF has no text layer), or the Canary attempt is blank. | Paste the invoice text instead of the file, or type a Canary attempt that is not blank. | | 400 | `invalid_amount` | Top-up API (quote) | The top-up amount is not a USDC amount, or is not between 1 and 1000 USDC. | Ask for a top-up of 1 to 1000 USDC, written as a plain decimal amount. | | 400 | `invalid_input` | Console invoices API, Canary attempt API, Extension API, Top-up API, TEE check API | The request body or query is malformed: a field is missing or has the wrong type, or the body is not the JSON or form the route reads. | Send the fields the route expects, in the shape its `detail` names when there is one. | | 400 | `invalid_link` | Alert links (stop) | The unsubscribe link is not a valid link for this subscription. | Use the unsubscribe link of a recent alert email, or turn the alert off on the vault's alerts page. | | 400 | `too_long` | Canary attempt API | The Canary attempt is longer than 4,000 characters. | Shorten the text to 4,000 characters at most and try again. | | 400 | `unreadable` | Console invoices API | The invoice file could not be read: it is empty or over 4 MiB, it is not the PDF it claims to be, the PDF could not be read in time, or the text file is not UTF-8. | Upload a readable PDF or a UTF-8 text file, or paste the invoice text. | | 401 | `bad_code` | Extension API (pairing) | The pairing code is wrong, used or expired. | Show a new code on the Browser extension page and enter that one. | | 401 | `bad_signature` | Sign-in API | The wallet's signature does not verify over the sign-in challenge, or its public key is not a full-access key of the account. | Sign in again with a full-access key of the account. | | 401 | `bad_token` | Extension API | The extension sent no token, or a token that is unknown, expired, revoked or made at the other network's address. | Connect the extension again: open Probarion, then Browser extension. | | 401 | `challenge_missing_or_expired` | Sign-in API | The sign-in challenge is missing, expired or already used. | Start the sign-in again, so the server issues a fresh challenge. | | 401 | `invalid_input` | Sign-in API | The signed login is malformed: the account id, the public key or the signature is missing or not in its expected form. | Sign in again from the wallet prompt, without changing what the wallet sends. | | 401 | `unauthenticated` | Console invoices API, Top-up API | There is no signed-in session at this address. | Sign in with your NEAR wallet and send the request again. | | 401 | `unauthorized` | Cron routes, Telegram webhook | The call did not carry the route's secret: the cron bearer secret, or the Telegram webhook secret. | Leave these routes to the scheduler and to Telegram, which hold their secrets. | | 403 | `bad_origin` | Sign-in API, Console invoices API, Canary attempt API, Top-up API, TEE check API | The request's Origin header is missing, or is not the address the request came in at. For the TEE check, the browser said in Sec-Fetch-Site that another site's page made the call. | Send the request from a Probarion page at this address. | | 403 | `forbidden` | Console invoices API, Top-up API | The signed-in account is not a member of this vault with a role that may do this; a top-up quote also needs the vault's owner on chain. | Sign in with an account that has the needed role on this vault. | | 403 | `not_member` | Extension API (queue an invoice) | The token's account is not an owner or an approver of this vault (a vault that does not exist gets the same answer), or the vault is a Canary vault, which takes no invoices from the extension; nothing was stored. | Choose a vault where the connected account is an owner or an approver, and not a Canary vault. | | 404 | `mandate_not_found` | Extension API (queue an invoice) | The vault's mandates, as the chain lists them now, have no mandate with this id; nothing was stored. | Choose a mandate that the extension lists for this vault. | | 404 | `not_found` | Proof files (snapshot.json, ledger.jsonl, payments.csv), Top-up API (faucet) | There is no proof to give: the vault id is not a NEAR account id, nobody registered the vault, or its proof page is not public and you are not a member. The faucet answers it at any address but testnet's, and to anyone but the vault's owner, so a vault id is not confirmed. | Check the vault id, or sign in as a member of the vault if its proof page is not public; for the faucet, sign in as the vault's owner at the testnet address. | | 409 | `asset_not_allowed` | Top-up API (quote) | The vault does not allow Base USDC, so a top-up could not pay a Base payee from it. | Top up a vault whose `allowed_assets` include Base USDC, since they are fixed when the vault is created. | | 409 | `busy` | Console invoices API, Canary attempt API | Another invoice for this vault, or another Canary attempt, is being processed. | Wait until the other run has finished, then send it again: an invoice upload can take up to 90 s and a Canary attempt up to 80 s. | | 409 | `duplicate` | Console invoices API | The vault already has this invoice: the same text once normalised, or this text again after `custom.invoice_number_duplicate` refused it while the number is still blocked. The body adds `invoiceId` and `status`. Another text with the number of an invoice that was paid or may still pay is not a 409: its cycle runs and that rule refuses it, and when the rule's refusal is the outcome's code the answer adds a `notice`. | Read the invoice's `status` instead of sending it again. A paid invoice is not paid again. A text the number rule refused runs again only once no invoice that may pay has the number, for example after the owner rejects the earlier invoice in the console while it waits for the owner. A refused text whose refusal could not be written answers this 409 with `status` `processing` whatever happens to the earlier invoice, and its number stays blocked. An approver's rejection of a held spend keeps the number blocked: the console does not yet free a number when that spend fails, is rejected or expires. | | 409 | `key_unavailable` | Console invoices API | The mandate's agent key is not active in this console, so nothing was sent. | Activate that key on the Keys tab, or create the mandate for an active key (the owner signs). | | 409 | `mainnet_only` | Top-up API | NEAR Intents and 1Click exist on mainnet only, so a testnet vault has no top-up. | On testnet, press "Add test USDC" on the vault's Top-up tab instead. | | 409 | `no_keys` | Console invoices API, Extension API (queue an invoice) | The vault has no active agent key, or no active ledger key, in this console; for the extension, the agent key must be the one the chosen mandate names. | Activate the missing key on the Keys tab (the owner signs). | | 409 | `no_mandate` | Console invoices API | No purpose document was attached, or no mandate of this vault has the attached document's hash as its `purpose_hash`. | Attach the purpose document (the PO or contract) you hashed when you created the mandate. | | 409 | `not_owner` | Top-up API (faucet) | The chain names another account than the signed-in one as the vault's owner, so nothing was minted. | Sign in with the account the chain names as the vault's owner. | | 409 | `wrong_network` | Console invoices API, Extension API (queue an invoice), Top-up API | The vault is on the other network than the address the request came in at. | Open the vault at its own network's address. | | 409 | `wrong_token` | Top-up API (faucet) | The vault's token is not testnet's USDC, so the faucet's test USDC would not count in it, and nothing was minted. | Use the faucet on a vault made for testnet's USDC; a vault's token is set when it is created. | | 411 | `length_required` | Console invoices API, Extension API (queue an invoice) | The upload has no valid Content-Length header. | Send the upload with a Content-Length header, as a browser form does. | | 413 | `payload_too_large` | Sign-in API | The sign-in body is larger than 2 KiB, which no signed login is. | Send only the signed login the wallet returned. | | 413 | `too_large` | Console invoices API, Extension API (queue an invoice) | The upload is too large: the invoice or the purpose document is over 4 MiB, or the whole request is over 4 MiB plus 64 KiB (4 MiB plus 16 KiB for the extension, which sends no purpose document). | Send a smaller file, or paste the invoice text in the console instead. | | 415 | `unsupported` | Console invoices API, Extension API (queue an invoice) | The invoice file is not a PDF or a plain text file. | Send the invoice as a PDF or a plain text (`.txt`) file, or paste its text in the console instead. | | 422 | `amount_unclear` | Extension API (queue an invoice) | The code could not read one clear USDC total from the invoice: the text has no total it can read, has totals that disagree, shows a zero total or another currency, is cut short, or is not a payable invoice; nothing was stored. | Upload the invoice in the console instead, or send an invoice that states one clear total. | | 422 | `empty` | Extension API (queue an invoice) | There is no text in the file (a scanned PDF has no text layer, or the text file is blank); nothing was stored. | Upload the invoice in the console instead, where you can paste its text. | | 422 | `mandate_inactive` | Extension API (queue an invoice) | The chosen mandate cannot take an invoice now: it is revoked or expired, has no uses or no total left, has a status, expiry, use count or total the code cannot read, or has a payee the owner's confirmation message cannot name; the `detail` names the mandate and the reason, and nothing was stored. | Choose another mandate of this vault, or create a new one for this payee (the owner signs). | | 422 | `refused_by_code` | Extension API (queue an invoice) | The code refused the invoice, and the body adds `rule`: `mandate.payee_mismatch` when the payee the invoice names contradicts the mandate's payee, or `contract.check_spend` when the vault's free `check_spend` view says the vault would refuse this payment now (the `detail` names the contract's code, for example `DuplicateInvoice`); nothing was stored. | Choose the mandate that pays the payee on the invoice, or look up the contract code in `detail` in the vault error codes and fix what it names. | | 422 | `unreadable` | Extension API (queue an invoice) | The invoice file could not be read: it is sent as a PDF but is not one, the PDF could not be read or took too long, or the text file is not UTF-8; the `detail` says which, and nothing was stored. | Send a readable PDF or a UTF-8 text file, or upload the invoice in the console instead. | | 429 | `budget` | Canary attempt API | The Canary's budget is used up for now: the day's model token budget, or the attempts allowed in total or from this address. | Wait the seconds in the `Retry-After` header, then try again. | | 429 | `rate_limited` | Sign-in API, Console invoices API, Canary attempt API, Top-up API, Extension API, TEE check API, proof files, alert confirm link, Telegram webhook | Too many requests in the route's window; the answer carries `Retry-After` and the detail `Try again in N s`. | Wait the seconds in the `Retry-After` header before you send the request again. | | 500 | `internal` | Console invoices API | The invoice pipeline returned a result the route does not know, which is a bug. | Report it, and read the vault's invoices and spends before you upload the invoice again. | | 502 | `failed` | Console invoices API, Canary attempt API | The cycle stopped before any payment was sent, or the Canary attempt ran out of time; the invoice or the attempt is stored as an error. | Upload the invoice again, or send a new Canary attempt. | | 502 | `mint_failed` | Top-up API (faucet) | The mint reached NEAR and the token refused it, so no test USDC arrived and the vault's day is used; the body adds `txHash`. | Send @probarionhq a direct message on X with your vault's name and the `txHash`. | | 502 | `quote_rejected` | Top-up API (quote) | 1Click's quote does not match what was asked for, so it is not used. | Ask for a new quote. | | 502 | `quote_unavailable` | Top-up API (quote) | 1Click could not give a quote. | Ask for a quote again in a minute. | | 502 | `status_unavailable` | Top-up API (status) | 1Click could not report the deposit's status. | Ask for the status again later, since 1Click also finds the deposit by itself. | | 503 | `chain_unavailable` | Console invoices API, Extension API (queue an invoice), Top-up API (quote) | The vault could not be read from NEAR, so nothing was stored or sent. | Try again when the NEAR RPC answers. | | 503 | `closed` | Canary attempt API | The Canary takes no attempts (its mode is off or replay), or its ledger is full. | Try the Canary again once it is open. | | 503 | `key_unavailable` | Console invoices API | A key cannot be opened on the server, so nothing was sent; the operator is alerted. | Try again later, once the operator has fixed the key. | | 503 | `not_configured` | Top-up API, Telegram webhook | This deployment has not set up the service the route needs: 1Click quotes for top-ups, or the Telegram bot. | Ask the deployment's operator to set the service up, since the request itself is fine. | | 503 | `not_ready` | Canary attempt API | The Canary is not ready on this network: its vault, model key, mandate, keys or ledger key are not usable, or the chain did not answer in time. | Try the Canary again later. | | 503 | `unavailable` | Sign-in API, Console invoices API, Canary attempt API, Top-up API, Extension API, TEE check API, proof files, alert links, cron routes, Telegram webhook | Something the route depends on failed (the database, a server key, a setting or the chain), and the route answers this instead of an unhandled error. | Try again in a moment, and quote the request id from the detail when you report it. | | 503 | `wrong_chain` | Sign-in API | An RPC address set for this network answers for the other chain, so no sign-in is accepted. | Tell the deployment's operator, since the fault is in its RPC setting. | ## Messages that are not Probarion codes | Message | Where it comes from | What it means | | - | - | - | | `Method is private` | near-sdk, on the vault's payout callbacks `on_transfer` and `on_withdraw`, and on the factory's callback `on_vault_created` | Only the contract itself may call its callbacks. Any other caller gets this message. | | `Method doesn't accept deposit` | near-sdk, on `freeze`, `spend`, `anchor`, `expire_held`, the payout callbacks, the `new` init of the vault and the factory, and the factory's `on_vault_created` | These methods refuse any attached deposit. View methods have no such guard. For `spend` the node refuses first, because the agent's function-call key cannot attach a deposit. | | `PayeeMismatch` | The SDK's rule `mandate.payee_mismatch`, off chain | It is not a contract code. `spend` has no address field, so the vault has no payee to compare. The SDK refuses by code, as `refused_by_code`, when the invoice or the model names a payee that contradicts the mandate's. See [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md). | ## Related pages - [How a payment is checked](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 spend checks, in the order that decides which code you get. - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): the seven statuses `process()` returns, and the SDK's rule ids. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): the life of a held spend, behind `NotHeld`, `HoldExpired` and `HoldNotExpired`. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): the limits behind the mandate codes. - [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md): who may call what, behind `NotOwner`, `NotApprover`, `NotAgent` and `NotLedgerKey`. - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): the payment loop, refs and retries, and how to find a spend by its ref. - The sources: [`contracts/vault/src/errors.rs`](https://github.com/probarion/probarion/blob/main/contracts/vault/src/errors.rs), [`packages/sdk/src/errors.ts`](https://github.com/probarion/probarion/blob/main/packages/sdk/src/errors.ts) and [`apps/web/src/lib/http.ts`](https://github.com/probarion/probarion/blob/main/apps/web/src/lib/http.ts). --- # SDK reference > Every export of @probarion/sdk and @probarion/sdk/testing: the Probarion client, chain clients, ledger stores, model adapter, proof, errors and test doubles. Every export of @probarion/sdk and its testing entry point, grouped by job: what each one is, what it does and which docs page shows it in use. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-12 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/sdk-reference `@probarion/sdk` lets your agent pay from a vault, only inside the mandates its owner signed. This page lists every export of its two entry points, `@probarion/sdk` and `@probarion/sdk/testing`, grouped by what each is for, with the public members of the classes most code calls. A test compares these tables with the package's code, so an export or a type that is added or removed, or a member of a class listed here, fails the docs tests until this page follows. ## Install ```bash npm install @probarion/sdk @probarion/core ``` The SDK's examples import from both packages, so install both. Release 0.1.1 is on npm. It is ESM only and needs Node 24 or newer. [Install the packages](https://probarion.xyz/docs/build/install.md) covers a build from a clone, the import path of each entry point and the environment variables. The SDK takes `"testnet"` or `"mainnet"`. The vaults run on NEAR testnet today, and one small vault, `v0.probarion.near`, runs on mainnet: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). ## How to read the tables - **Kind** is class, function, constant or type. A type has no runtime value: TypeScript code imports it with `import type`. - **Shown on** names the docs page that shows the export in use or explains it. None means no page does yet. - **Signed by** names the key that signs what a member sends. None means it signs nothing. - **Every row is an anchor.** An export's anchor is `sdk-` and its name in lowercase, such as [`#sdk-vaultclient`](#sdk-vaultclient). A test double's is `sdk-testing-` and its name, such as [`#sdk-testing-fakechain`](#sdk-testing-fakechain). A member's is `sdk-`, its class and its name, such as [`#sdk-vaultclient-listallspends`](#sdk-vaultclient-listallspends), and a constructor's ends in `new`, such as [`#sdk-vaultclient-new`](#sdk-vaultclient-new). The tables leave out what other pages already cover: - The options of `new Probarion()`, one by one: [Create the instance](https://probarion.xyz/docs/build/let-an-agent-pay.md#create-the-instance). - The fields of a `SpendOutcome` and what each status means: [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md). - Each error class's parent, `.code` and fields: [SDK errors](https://probarion.xyz/docs/build/errors.md#sdk-errors). The members of the other classes, such as `NearAiProposer` and the test doubles, and the fields of the other option types, are in the [source](https://github.com/probarion/probarion/tree/main/packages/sdk/src). ## Who signs > **Owner signs** > > The SDK has no method for any call the owner signs: vault create, freeze, unfreeze, withdraw and withdraw-intents, mandate create and revoke, agent add and remove, and ledger set-key. The owner signs each one in their own wallet, and the [CLI](https://probarion.xyz/docs/build/cli-reference.md) prints them for that. > > _The owner signs this transaction. Probarion never signs it._ The SDK holds two keys, both function-call keys on the vault account. The agent key can call only `spend`. The ledger key can call only `anchor`, and it also signs every ledger entry. `process()` and `requestSpend()` send `spend` with the agent key and `anchor` with the ledger key, and a `VaultClient` sends them through the senders you give it. No other member of `Probarion` or `VaultClient` signs anything. A `NearApiJsSender` signs whatever function call it is handed, with the key it was made with, and `Probarion` makes one over each of those two keys and no other. [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) explains the keys. > **Approver signs** > > A held spend waits for one of the vault's approvers, who approves or rejects it from their own account. The SDK has no method for either. See [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md). > > _An approver signs this transaction. Probarion never signs it._ ## The client `Probarion` is the class most code needs: one instance per vault, in one process. With no keys and `model: null` an instance is read-only. The types in this table are what you pass to it and get back from it. | Name | Kind | What it does | Shown on | | - | - | - | - | | `Probarion` | class | The client for one vault. `process()` runs the payment cycle for an invoice, the read members read the vault, and `snapshot()` and `verify()` gather and check the proof. | Let an agent pay, Developer quickstart | | `hashInvoice` | function | The invoice hash: the sha256 of the text's UTF-8 bytes, as 64 lowercase hex. Bytes that are not UTF-8 throw `ProbarionError` with the code `InvalidInput`. | Let an agent pay | | `DEFAULT_LEDGER_PATH` | constant | `.probarion/ledger.jsonl`: the ledger file when neither the `ledger` option nor `PROBARION_LEDGER_PATH` names one. | None | | `UNKNOWN_SETTLE_MS` | constant | 48 hours in milliseconds. After an unconfirmed payment, the same invoice is refused by code with `ledger.unresolved` until the chain shows its spend or this time has passed. | Spend outcomes | | `ProbarionOptions` | type | The first argument of `new Probarion()`: `network`, `vault`, `agentKey`, `ledgerKey`, `rpcUrls`, `model`, `ledger`, `confidenceFloor` and `requestId`. | Let an agent pay | | `ProbarionDeps` | type | The optional second argument of `new Probarion()`, for tests and hosts. Production code passes nothing. | None | | `ProbarionLogger` | type | Anything with `warn(message, meta?)`, which receives the instance's warnings. | None | | `VerifyOptions` | type | The options of `verify()`: `online`, `snapshot`, `invoiceTexts` and `systemPrompts`. | None | | `SpendOutcome` | type | What `process()` and `requestSpend()` return: `status`, `spendId`, `code`, `txHash`, the ledger fields and `modelUsage`. | Spend outcomes | | `SpendOutcomeStatus` | type | The eight statuses of a `SpendOutcome`. `process()` never returns `settled`. | Spend outcomes | | `ModelUsage` | type | The model id and token counts of a model step that answered. | Spend outcomes | | `InvoiceFacts` | type | The facts your agent supplies with each invoice: `invoice_hash`, `amount`, `po_ref`, `purpose_hash` and `payee_hint`. From `@probarion/core`. | Let an agent pay | | `MandateView` | type | A mandate as the vault's views return it, with its payee, limits, uses, expiry and status. From `@probarion/core`. | Install the packages | | `CustomRule` | type | A rule for `rules.add()`: an `id` that starts with `custom.` and a `check(input, mandate)` that returns a pass, a hold or a refusal. From `@probarion/core`. | Let an agent pay | | `Hex32` | type | A `string` that holds 64 lowercase hex characters, such as a sha256. The type does not check the format. From `@probarion/core`. | None | ### Probarion members `process()` and `requestSpend()` return a `SpendOutcome` for everything the vault or the node answers about the spend. [Thrown or returned](https://probarion.xyz/docs/build/errors.md#thrown-or-returned) lists when they throw instead. | Member | What it does | Signed by | Shown on | | - | - | - | - | | `new Probarion(opts, deps?)` | Makes the client. `opts` is a `ProbarionOptions` and `deps` a `ProbarionDeps`. A bad network, vault, key, model or `confidenceFloor` throws `ConfigError`. | None | Let an agent pay | | `network` | The network the instance was made for: `testnet` or `mainnet`. | None | None | | `vaultId` | The vault account id. | None | None | | `process(invoice)` | The whole cycle for one invoice `{ text, facts, ref }`: the optional model proposal, the decision, then, unless the code refuses, the free `check_spend` and at most one `spend`, and the ledger entries and the anchor. Returns a `SpendOutcome`. Calls on one instance run one at a time. | The agent key signs `spend`. The ledger key signs each entry and the `anchor`. | Let an agent pay, Spend outcomes | | `requestSpend(input)` | The same path for a decision you made yourself: `{ mandateId, amount, invoiceHash, ref }`, with the optional `proposal`, `evidence` and `skipPreflight`. It has no recipient parameter. The outcome's `modelUsage` is null. | The agent key signs `spend`. The ledger key signs each entry and the `anchor`. | Spend outcomes | | `checkSpend(input)` | The contract's free dry run of `spend` for `{ mandateId, amount, invoiceHash, ref }`, with no transaction and no ledger entry. Returns `{ ok, code, would_hold }`. Without an agent key it checks as the key the mandate names. | None | Install the packages, Developer quickstart | | `mandates.list()` | Every mandate of the vault, read from the chain. | None | Mandates, Install the packages, Developer quickstart | | `mandates.get(id)` | One mandate by its id, or null. | None | Mandates | | `spends.get(id)` | One spend by its spend id, or null. It takes no ref: to find a spend by its ref, list the spends with `VaultClient.listAllSpends()`. | None | Let an agent pay | | `anchors.latest()` | The vault's latest ledger anchor on chain, or null. | None | The ledger and anchors | | `rules.add(rule)` | Adds a `CustomRule` to every later decision. An id that does not start with `custom.`, or a rule with no `check`, throws `ConfigError`. | None | Let an agent pay | | `snapshot(opts?)` | The chain facts the verifier needs, gathered with your ledger, as a `Snapshot`. `online: true` also looks burns up in FastNEAR's transaction index. | None | None | | `verify(opts?)` | Runs the verifier checks a to i on a fresh snapshot, or on `opts.snapshot`, and returns a `VerifyReport`. `invoiceTexts` adds A8, and `online: true` adds the online attestation checks to check e. | None | Verifier checks a to i | ### Dependencies for tests and hosts The constructor's optional second argument, a `ProbarionDeps`, replaces the parts the instance would make for itself: the RPC client, the senders, the model step, the clock, the logger and the sources of the proof. The SDK's own tests pass a proposer over the fake NEAR AI Cloud, a fixed clock and a logger here, and point `rpcUrls` at a fake RPC server. Production code passes nothing. | Field | What it sets | Default | | - | - | - | | `rpc` | The `RpcClient` for every read, with `archivalRpc` for old burn lookups. The default senders send to its current URL, without its failover. | A client over `rpcUrls`, else `NEAR_RPC_URLS`, else the network's RPC list. | | `agentSender` | The `TxSender` that signs `spend`. It must sign as the vault account, and with the public key of `agentKey` when both are given. | A `NearApiJsSender` over `agentKey`, or none. | | `ledgerSender` | The `TxSender` that signs `anchor`. | A `NearApiJsSender` over `ledgerKey`, or none. | | `proposer` | The `NearAiProposer` for the model step. `null` decides with the code rules only, even when `model` is set. | One made from the `model` option, or none. | | `nearaiBaseUrl` | The NEAR AI Cloud base URL of the default proposer. | NEAR AI Cloud's own URL. | | `now` | The clock: a function that returns a `Date`. | The system clock. | | `requestId` | Makes each cycle's `request_id` when the `requestId` option is not set. | A fresh UUID per cycle. | | `logger` | A `ProbarionLogger` that receives the warnings. | One JSON line per warning on `console.warn`. | | `expectedCodeHashes` | Deprecated and ignored: a snapshot lists exactly the code hashes check f trusts. | None | | `env` | The environment the SDK reads `NEAR_RPC_URLS`, `NEAR_ARCHIVAL_RPC_URLS`, `PROBARION_LEDGER_PATH` and `NEARAI_MODEL` from. | `process.env` | | `archivalRpc` | The archival `RpcClient` for burn lookups older than about 2.5 days. `null` turns archival lookups off. | A client over `NEAR_ARCHIVAL_RPC_URLS`, else FastNEAR's archival node. | | `preLedgerDecisionHashes` | The documented pre-ledger decision hashes for check d. The code applies them only to `v0.probarion.near` on mainnet, which has run there since 2026-11-11. | The sha256 of each `v0-decision-*.json` file in `evidenceDir`. | | `evidenceDir` | The folder of those decision files, read only for that mainnet vault. | `docs/evidence` | | `burnTxHints` | Spend ids mapped to the transactions that may hold their burn, tried first by check h. | The vault's transactions in `docs/mainnet-proof.json`, or none when the file is missing. | | `onlineVerifier` | The `OnlineAttestationVerifier` that `verify({ online: true })` uses. `null` means unavailable. | `@nearai/inference-sdk`, when it is installed. | | `txIndex` | The `TxIndex` used only online. `null` turns it off. | FastNEAR's transactions API. | ## Chain clients and transactions Each client reads one contract and is read-only, except that a `VaultClient` given senders also sends `spend` and `anchor`. An `RpcClient` carries every read, with failover across its URLs. To find a spend by its ref, list the vault's spends, as [Find a spend by its ref](https://probarion.xyz/docs/build/let-an-agent-pay.md#find-a-spend-by-its-ref) shows: the contract has no lookup by ref. | Name | Kind | What it does | Shown on | | - | - | - | - | | `VaultClient` | class | Reads one vault's views, and sends `spend` and `anchor` through the senders it is given. With no senders it is read-only. | Let an agent pay, Spend outcomes | | `FactoryClient` | class | Reads the Probarion factory's views: its code versions, a vault's version, its config and its vault list. Read-only. | None | | `IntentsClient` | class | Reads the NEAR Intents verifier's views for an account: its public keys, its predecessor auth flag and a token balance. Read-only. | None | | `RpcClient` | class | A NEAR JSON-RPC client that fails over across a list of URLs, with retries and back-off. | Let an agent pay | | `resolveRpcUrls` | function | The RPC URLs for a network: the list you pass, else `NEAR_RPC_URLS`, else the network's own list. | Let an agent pay | | `NearApiJsSender` | class | A `TxSender` that signs one function call with a secret key through near-api-js and waits for the final outcome. `Probarion` makes one over the agent key and one over the ledger key, both signing as the vault account. | None | | `SPEND_GAS` | constant | 200 Tgas: the gas `VaultClient.spend()` attaches, with no deposit. | None | | `ANCHOR_GAS` | constant | 30 Tgas: the gas `VaultClient.anchor()` attaches, with no deposit. | None | | `PAGE_SIZE` | constant | 50: the page size of `listAllMandates()`, `listAllSpends()` and `listAllAnchors()`. | None | | `MAX_PAGE_LIMIT` | constant | 100: the most rows a list view returns. A larger `limit` is cut to it. | None | | `pageAll` | function | Reads a list view page by page until a short page. A page longer than asked for, a repeated page or more than 1000 pages throws `ViewError`. | None | | `interpretSpend` | function | Reads a finished `spend` transaction as a `SpendTxResult`: paid, burned or held; not paid; or unconfirmed when it may have paid. | None | | `isSpendResult` | function | True when a value is a spend result the vault returns: a numeric `spend_id` and a known spend status. | None | | `parseTxOutcome` | function | Reads a transaction result as a `TxOutcome`. A vault panic gives its code, and the chain's balance refusals give `LackBalanceForState` or `NotEnoughBalance`. | None | | `firstReceiptSucceeded` | function | Whether the first receipt of a finished transaction succeeded: `true`, `false`, or `null` when the result shows no receipts. | None | | `isOutOfNearCode` | function | True for `LackBalanceForState` and `NotEnoughBalance`: the vault needs a plain NEAR transfer. | Errors | | `LACK_BALANCE_CODE` | constant | `LackBalanceForState`: the chain refused the call because the vault would fall below the NEAR its storage needs. | Errors | | `NOT_ENOUGH_BALANCE_CODE` | constant | `NotEnoughBalance`: the vault holds less NEAR than the call must prepay for gas. | Errors | | `OUT_OF_NEAR_DETAIL` | constant | The message for `LackBalanceForState`, which says each paid spend keeps about 0.0048 NEAR of storage. | Errors | | `OUT_OF_GAS_NEAR_DETAIL` | constant | The message for `NotEnoughBalance`, which puts a spend's gas at about 0.02 NEAR for 200 Tgas. | Errors | | `toAccessKeyInfo` | function | Reads one key of a `view_access_key_list` answer. An unknown or malformed permission throws `RpcMalformedError`. | None | | `codeIdentity` | function | An account's code identity: its global contract hash when it has one (NEP-591), else its code hash. | None | | `toTxStatusResult` | function | Reads a `tx` RPC result as a `TxStatusResult`. A malformed answer throws `RpcMalformedError`. | None | | `RpcClientOptions` | type | The options of `new RpcClient()`: `urls`, `fetch`, `maxRounds` (1 to 10, default 3), `baseDelayMs`, `maxDelayMs`, `timeoutMs`, `sleep` and `remainingMs`. | None | | `ViewAccountResult` | type | What `RpcClient.viewAccount()` returns. | None | | `TxStatusResult` | type | What `RpcClient.txStatus()` returns. | None | | `ExecutionOutcomeView` | type | One execution outcome inside a `TxStatusResult`. | None | | `VaultSenders` | type | The `agent` and `ledger` senders of a `VaultClient`. | None | | `SpendTxResult` | type | What `VaultClient.spend()` and `resolveSpend()` return: `tx`, `result`, `ok` and `unconfirmed`. Read `ok` first. | None | | `SpendUnconfirmed` | type | Why a spend may have paid although the answer cannot say: `kind` and `reason`. | None | | `SpendUnconfirmedKind` | type | `unreadable_value`, `callback_failed` or `pending`. | Errors | | `TxSender` | type | Signs and sends one function call: `accountId`, `publicKey`, `functionCall(tx)` and an optional `lookup(txHash)`. | None | | `FunctionCallTx` | type | One function call: `receiverId`, `methodName`, `args`, `gas` and `deposit`. | None | | `TxOutcome` | type | What a sender returns: `txHash`, `ok`, `value`, `code`, `failure` and `raw`. | None | | `TxFailureCode` | type | A vault panic code, `LackBalanceForState` or `NotEnoughBalance`. | Errors | | `NearApiJsSenderOptions` | type | The options of `new NearApiJsSender()`: `accountId`, `secretKey`, `rpcUrl`, `createProvider` and `sendTimeoutMs`, 90 s per step by default. | None | ### Chain client members | Class | Member | What it does | Signed by | | - | - | - | - | | `VaultClient` | `new VaultClient(vaultId, rpc, senders?)` | Makes a client for one vault. `senders` holds the agent and ledger `TxSender`. Each must sign as the vault account, or the call that needs it throws `ConfigError`. | None | | `VaultClient` | `vaultId` | The vault account id. | None | | `VaultClient` | `canSpend` | True when an agent sender was given. | None | | `VaultClient` | `canAnchor` | True when a ledger sender was given. | None | | `VaultClient` | `getConfig()` | The vault's config, from `get_config`. | None | | `VaultClient` | `getMandate(mandateId)` | One mandate, or null, from `get_mandate`. | None | | `VaultClient` | `listMandates(fromIndex, limit)` | One page of mandates from `list_mandates`. `fromIndex` is a 0-based position, and `limit` is cut to 1 to 100. | None | | `VaultClient` | `listAllMandates()` | Every mandate, page by page. | None | | `VaultClient` | `getSpend(spendId)` | One spend by its id, or null, from `get_spend`. | None | | `VaultClient` | `listSpends(fromIndex, limit)` | One page of spends from `list_spends`. | None | | `VaultClient` | `listAllSpends()` | Every spend, page by page. Use it to find a spend by its ref. | None | | `VaultClient` | `listAnchors(fromIndex, limit)` | One page of anchors from `list_anchors`. | None | | `VaultClient` | `listAllAnchors()` | Every anchor, page by page. | None | | `VaultClient` | `listAgents()` | The vault's agent keys, from `list_agents`. | None | | `VaultClient` | `checkSpend(request, signerPk)` | The contract's free `check_spend` view for that public key. The code comes back without its `PROBARION:` prefix. | None | | `VaultClient` | `spend(request)` | Sends `spend` with 200 Tgas and no deposit, and returns a `SpendTxResult`. A vault refusal comes back with `ok: false`. A node refusal throws `TxRejectedError`, and a lost answer throws `TxOutcomeUnknownError`. | The agent key | | `VaultClient` | `resolveSpend(txHash)` | Reads the final outcome of a spend whose outcome was unknown, through the agent sender's `lookup`, and gives it the same verdict as `spend`. | None: it signs nothing | | `VaultClient` | `anchor(seq, root, count)` | Sends `anchor` with 30 Tgas and no deposit, and returns the `TxOutcome`. A missing ledger sender, a `seq` or `count` below 1, or a `root` that is not 64 lowercase hex throws `ConfigError`. A failure on chain throws `ContractError`, a node refusal `TxRejectedError` and a lost answer `TxOutcomeUnknownError`. | The ledger key | | `FactoryClient` | `new FactoryClient(factoryId, rpc)` | Makes a read-only client for one factory. | None | | `FactoryClient` | `factoryId` | The factory account id. | None | | `FactoryClient` | `versions()` | The vault code versions the factory lists, from `versions`. | None | | `FactoryClient` | `vaultVersion(accountId)` | The version the factory records for that vault, or null, from `vault_version`. | None | | `FactoryClient` | `getConfig()` | The factory's config, from `get_config`. | None | | `FactoryClient` | `listVaults(fromIndex, limit)` | One page of the factory's vault records, from `list_vaults`. | None | | `IntentsClient` | `new IntentsClient(intentsId, rpc)` | Makes a read-only client for the NEAR Intents verifier: `intents.near`, or the stand-in on testnet. | None | | `IntentsClient` | `intentsId` | The verifier's account id. | None | | `IntentsClient` | `publicKeysOf(accountId)` | The public keys registered for the account on NEAR Intents. A vault must have none. | None | | `IntentsClient` | `isAuthByPredecessorIdEnabled(accountId)` | Whether auth by predecessor id is on for the account. | None | | `IntentsClient` | `poaBalance(accountId, asset)` | The account's balance of the token `nep141:` inside NEAR Intents, as a `bigint`. An answer that is not a balance throws `ViewError`. | None | | `RpcClient` | `new RpcClient(opts)` | Makes a client from `RpcClientOptions`. An empty URL list throws `ConfigError`. | None | | `RpcClient` | `currentUrl` | The URL the client tries first now. | None | | `RpcClient` | `call(method, params)` | Sends one JSON-RPC request, failing over across the URLs. It is safe only for reads and signed payloads, because a retry sends the same params again. | None | | `RpcClient` | `viewFunction(contractId, methodName, args?)` | Calls a contract view at final finality and parses its JSON answer. A failed view throws `ViewError`. | None | | `RpcClient` | `viewAccessKeyList(accountId)` | The account's access keys. An unknown permission throws. | None | | `RpcClient` | `viewAccessKey(accountId, publicKey)` | One access key, or null when the key or its account is not there. | None | | `RpcClient` | `viewAccount(accountId)` | The account's balance, locked balance, code hash, global contract hash and storage use. | None | | `RpcClient` | `txStatus(txHash, senderAccountId)` | A transaction's status once it is final, as a `TxStatusResult`. | None | | `NearApiJsSender` | `new NearApiJsSender(opts)` | Makes a sender from `NearApiJsSenderOptions`. A key that does not parse throws `ConfigError`, whose message names the account and never the key. | None | | `NearApiJsSender` | `accountId` | The account the key lives on. For the agent key and the ledger key, that is the vault. | None | | `NearApiJsSender` | `publicKey` | The public key of the secret key. | None | | `NearApiJsSender` | `functionCall(tx)` | Signs one `FunctionCallTx`, computes its hash before it sends anything, and waits for the final outcome, a `TxOutcome`. | The secret key it was made with | | `NearApiJsSender` | `lookup(txHash)` | Reads the final outcome of a transaction this sender signed. It throws `TxOutcomeUnknownError` when the node cannot say. | None | ## Ledger stores `Probarion` writes every decision and every payment to a `LedgerStore`: a `FileLedgerStore` at `PROBARION_LEDGER_PATH`, else `.probarion/ledger.jsonl`, unless you pass another store as `ledger`. Run one writer per vault, as [One ledger writer per vault](https://probarion.xyz/docs/build/let-an-agent-pay.md#one-ledger-writer-per-vault) explains. [The ledger and anchors](https://probarion.xyz/docs/concepts/ledger-and-anchors.md) covers what an entry records. | Name | Kind | What it does | Shown on | | - | - | - | - | | `FileLedgerStore` | class | The default `LedgerStore`: a JSONL file with one entry per line. Each append takes a lock file beside it and is synced to disk. | The ledger and anchors, Let an agent pay | | `InMemoryLedgerStore` | class | A `LedgerStore` in memory, for tests. | The ledger and anchors | | `ledgerPathFromEnv` | function | The ledger path from `PROBARION_LEDGER_PATH`, where a blank value counts as unset, else the fallback you pass. | None | | `composeBody` | function | Builds an entry body from its parts with exactly the body's keys in a fixed order, so nothing else reaches the hash. | None | | `LedgerStore` | type | What a ledger store provides: `head()`, `append(body, sign, opts?)` and `all()`. A store of your own must honour `opts.expect`. | Let an agent pay | | `FileLedgerStoreOptions` | type | `lockTimeoutMs`: how long an append waits for another writer, 10 s by default. | None | | `LedgerAppendBody` | type | An entry body without `seq` and `prev_link`, which the store fills in. | None | | `LedgerAppendOptions` | type | `expect`: the position an append must write at. | None | | `AppendExpectation` | type | A ledger position: `seq` and `prev_link`. | None | ### Ledger store members | Class | Member | What it does | | - | - | - | | `FileLedgerStore` | `new FileLedgerStore(path?, options?)` | Opens the file store. `path` defaults to `.probarion/ledger.jsonl`, and a blank one counts as unset. A missing file is an empty ledger. | | `FileLedgerStore` | `path` | The ledger file's path. | | `FileLedgerStore` | `all()` | Every entry, in order. It does not check the chain, which is the verifier's job. A torn last line throws `LedgerError` naming the line, and nothing is repaired. | | `FileLedgerStore` | `head()` | The last entry, or null for an empty ledger. | | `FileLedgerStore` | `append(body, sign, opts?)` | Under the lock file, checks that the entry chains onto the head, signs it with `sign` and writes it. With `opts.expect` it writes only at that position, and otherwise throws `LedgerConflictError` and writes nothing. | | `InMemoryLedgerStore` | `new InMemoryLedgerStore(initial?)` | Starts from a copy of `initial`, or empty. | | `InMemoryLedgerStore` | `all()` | Copies of every entry, in order. | | `InMemoryLedgerStore` | `head()` | A copy of the last entry, or null. | | `InMemoryLedgerStore` | `append(body, sign, opts?)` | The same chain check and compare-and-append as the file store, under one lock inside the process. | ## Model advice and attestation With `model: { provider: "near-ai-cloud", apiKey }`, `Probarion` makes a `NearAiProposer` and records the attestation of each proposal. The model only advises: the code rules still run on its proposal, and the contract checks the spend again. A real attested decision is Preview, as [Choose who decides](https://probarion.xyz/docs/build/let-an-agent-pay.md#choose-who-decides) says. The ones recorded in the repository are all on testnet. The Canary's ledger of 2026-11-10 holds two, which pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/README.md#the-signed-ledger-published-2026-11-11), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/canary-vault/verify-online-2026-11-11.txt)). The demo vault's ledger holds five, made by Payables, which pass A1 to A7 offline; four of them, entries 10 to 13, also passed O1 and O2 online on 2026-11-12. The code allowed the last two of them, and the vault paid both invoices ([evidence](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-payables-pays-on-its-own-with-an-attested-model), [the second](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/README.md#2026-11-12-filmed-a-second-invoice-paid-on-its-own-freeze-and-unfreeze), [online output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-online-2026-11-12.txt)). The TEE measurement is not pinned, so this does not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository ([output](https://github.com/probarion/probarion/blob/main/docs/evidence/demo-vault/verify-a8-2026-11-12.txt)). The online checks need the optional peer dependency `@nearai/inference-sdk`. | Name | Kind | What it does | Shown on | | - | - | - | - | | `NearAiProposer` | class | The NEAR AI Cloud adapter: asks a private model in a TEE for one structured proposal and captures its attestation evidence. `propose(input)` returns a `ProposeResult`. A missing API key throws `NearAiError`. | Let an agent pay | | `PREFERRED_PRIVATE_MODELS` | constant | The private TEE model ids tried first when the catalog lists them as eligible. | None | | `CATALOG_PAGE_SIZE` | constant | 100: the page size for reading the model catalog. | None | | `MAX_RESPONSE_BODY_BYTES` | constant | 64 KiB. A model answer larger than this is not used, and the decision is made by the code rules only. | None | | `DEFAULT_NEARAI_TIMEOUTS_MS` | constant | The timeout of each kind of call: catalog 10 s, report 15 s, chat 90 s and signature 15 s. | None | | `redactSecrets` | function | Removes anything that looks like a credential from text before it reaches a log: the secrets you pass, `Bearer` tokens, API key fields and `sk-` keys. | None | | `parseModelCatalogPage` | function | Reads one page of the public model catalog, `GET /v1/model/list`. | None | | `isAttestedStructuredModel` | function | True for a catalog model that runs on vLLM with attestation, is verifiable and supports structured outputs. | None | | `parseModelReports` | function | Reads a model's attestation reports, and drops any without an ECDSA signer, a quote or a 64-hex request nonce. | None | | `selectReport` | function | Picks the one report whose signer signed this response. None, or more than one, gives null, which leaves the decision unattested. | None | | `buildProposalRequest` | function | Builds the exact chat request body as canonical JSON, so the same inputs always give the same bytes. From `@probarion/core`. | None | | `PROPOSAL_REQUEST_TEMPLATE` | constant | `probarion-proposal/v1`: the template id recorded with each request. From `@probarion/core`. | None | | `PROPOSAL_SYSTEM_PROMPT` | constant | The default system message of the proposal request. From `@probarion/core`. | None | | `ACCEPTED_TCB_STATUSES` | constant | The TCB statuses the online quote check accepts: `UpToDate`, `OutOfDate` and `TDRelaunchAdvised`. | None | | `O1_MEASUREMENT_PINNED` | constant | `false`: the TEE measurement is not pinned yet, so `quote_verified` stays false whatever the online quote check says. | None | | `o1PolicyProblem` | function | Why a DCAP-verified quote fails the online check's policy for this evidence, or null when it passes. | None | | `nvidiaPayloadNonce` | function | The nonce inside an NVIDIA GPU payload, as 64 lowercase hex, or null. | None | | `loadDefaultOnlineVerifier` | function | Loads the online verifier from the optional peer dependency `@nearai/inference-sdk`, or gives null when it cannot be loaded. | None | | `applyOnlineChecks` | function | Folds the online quote and GPU checks into check e of a `VerifyReport`. An offline failure stays a failure. | None | | `NearAiProposerOptions` | type | The options of `new NearAiProposer()`, from `apiKey` and `model` to the timeouts. | None | | `NearAiPhase` | type | The four kinds of call: `catalog`, `report`, `chat` and `signature`. | None | | `CatalogModel` | type | One model of the catalog, with the flags that make it eligible. | None | | `ProposeInput` | type | What `propose()` takes: the invoice text, the mandates, the nonce and the facts. | None | | `ProposeResult` | type | What `propose()` returns: the proposal, its evidence and the token usage. | None | | `RawModelReport` | type | One attestation report from the model, before one is selected. | None | | `ModelProposal` | type | A model's proposal: `action`, `mandate_id`, `amount`, `payee_hint`, `reasoning` and `confidence`. From `@probarion/core`. | Why spend has no payee | | `AttestationEvidence` | type | The evidence a ledger entry carries with a model proposal, from the request hash to the signature. From `@probarion/core`. | None | | `OnlineAttestationVerifier` | type | The online checks: DCAP verification of the TDX quote, and NVIDIA's verification of the GPU payload. | None | | `VerifiedTdxQuote` | type | What DCAP verification says about a TDX quote. | None | ## Snapshot and proof `snapshot()` and `verify()` use these, so you need them directly only to build a snapshot or a check of your own. A few concern only the mainnet vault `v0.probarion.near`, deployed on 2026-11-11 with no ledger: the code names it as the only vault whose spends may predate the signed ledger. [Verifier checks a to i](https://probarion.xyz/docs/trust/verifier-checks.md) says what each check proves, and [Verify a vault yourself](https://probarion.xyz/docs/trust/verify-a-vault.md) runs the verifier on a live vault. | Name | Kind | What it does | Shown on | | - | - | - | - | | `gatherSnapshot` | function | Reads every chain fact the verifier needs for one vault and returns a `Snapshot`. `Probarion.snapshot()` calls it. | None | | `collectBurns` | function | Finds the burn pair of every burned spend: in the hinted transactions, then the ones the ledger records, then, online, the account's transactions in the index. | None | | `findBurnPair` | function | Finds the POA token's `ft_burn` and NEAR Intents' `mt_burn` in one transaction. | None | | `expectedCodeHashes` | function | The code hashes a snapshot lists: the known vault hashes, plus the canonical factory's versions when the vault names that factory. | None | | `fastnearTxIndex` | function | A `TxIndex` over FastNEAR's transactions API, used only online. | None | | `resolveArchivalRpcUrls` | function | The archival RPC URLs: `NEAR_ARCHIVAL_RPC_URLS`, else FastNEAR's archival node for the network. | None | | `DEFAULT_ARCHIVAL_RPC_URLS` | constant | FastNEAR's archival RPC, for mainnet and for testnet. | None | | `ARCHIVAL_AFTER_NS` | constant | About 2.5 days in nanoseconds. A transaction older than this is looked up on the archival node first. | None | | `PRE_LEDGER_VAULTS` | constant | The only vault the code exempts as pre-ledger: `v0.probarion.near` on mainnet, which has run there since 2026-11-11 with no ledger. Testnet has none. | None | | `isPreLedgerVault` | function | True for a vault in `PRE_LEDGER_VAULTS`. | None | | `readPreLedgerDecisionHashes` | function | The sha256 of each `v0-decision-*.json` file in a folder, in name order. A missing folder gives none. | None | | `PRE_LEDGER_DECISION_FILE` | constant | `v0-decision-*.json`: the file name pattern of those decision records, as a regular expression. | None | | `DEFAULT_EVIDENCE_DIR` | constant | `docs/evidence`, relative to the working folder. | None | | `DEFAULT_PROOF_FILE` | constant | `docs/mainnet-proof.json`: the evidence file the burn hints are read from. | None | | `loadBurnTxHints` | function | Reads one vault's burn transaction hints from that evidence file. A missing file gives none. | None | | `Snapshot` | type | The chain facts and the ledger that the verifier checks. From `@probarion/core`. | Verify a vault yourself | | `VerifyReport` | type | The verifier's result: `ok`, one result per check a to i, and the burns it could not prove. From `@probarion/core`. | Verifier checks a to i | | `SnapshotNotes` | type | What a gathered snapshot could not establish, by check. | None | | `GatherSnapshotArgs` | type | The arguments of `gatherSnapshot()`. | None | | `CollectBurnsArgs` | type | The arguments of `collectBurns()`. | None | | `BurnPair` | type | What `findBurnPair()` found in a transaction: the memo, the NEAR Intents owner and the amount of the burn. | None | | `BurnTxHint` | type | A transaction that may hold a burn: its hash, with its signer when known. | None | | `BurnTxHints` | type | Spend ids mapped to their burn hints. | None | | `TxIndex` | type | A transactions-by-account index, used only online. | None | | `TxIndexEntry` | type | One transaction an index lists. | None | ## Errors Every error class extends `ProbarionError`, so one `instanceof ProbarionError` catches them all. [SDK errors](https://probarion.xyz/docs/build/errors.md#sdk-errors) gives each class its parent, its `.code` and its fields, anchored by its name, such as [`ConfigError`](https://probarion.xyz/docs/build/errors.md#ConfigError). | Name | Kind | What it does | Shown on | | - | - | - | - | | `ProbarionError` | class | The base class of every SDK error. `.code` names the case. | Errors | | `RpcError` | class | Every RPC URL failed, a node answered with an error, or the time ran out. | Errors | | `RpcMalformedError` | class | A node answered in a shape the client cannot read. | Errors | | `ViewError` | class | A view call failed or returned something unexpected. | Errors | | `ContractError` | class | A transaction the SDK sent failed on chain, such as a refused `anchor`. | Errors | | `TxOutcomeUnknownError` | class | A transaction was sent and its outcome is not known. | Errors | | `TxRejectedError` | class | The node refused the signed transaction before it ran. | Errors | | `ConfigError` | class | An option or a key is missing or malformed. | Errors | | `NearAiError` | class | The model step failed. | Errors | | `NearAiResponseTooLargeError` | class | The model's answer is larger than 64 KiB. | Errors | | `LedgerError` | class | The ledger store failed. | Errors | | `LedgerConflictError` | class | Another writer moved the ledger first, so nothing was written. | Errors | | `extractProbarionCode` | function | Pulls a known `PROBARION:` out of a string, an error or a nested RPC failure, or gives null. | Errors | | `isVaultErrorCode` | function | True when a value is one of the vault's panic codes. | Errors | | `normalizeVaultCode` | function | Strips the `PROBARION:` prefix from a code. The result may still be a code the SDK does not know. | Errors | | `RpcAttempt` | type | One failed try in an `RpcError`'s attempt log: the URL's origin and the error. | None | | `ViewErrorContext` | type | The attempts, RPC error and cause a `ViewError` carries over. | None | ## Helpers The three spend helpers mirror what the contract counts, so the code can refuse early what `check_spend` would refuse anyway. The contract is authoritative. | Name | Kind | What it does | Shown on | | - | - | - | - | | `envValue` | function | Reads an environment variable, treating an empty or blank value as unset, and trims a set one. | None | | `Mutex` | class | Runs tasks one at a time, in call order, with `run(task)`. A failed task does not block the next one. | None | | `spentToday` | function | The total of live spends created on the UTC day of `now`, counted the way the contract counts its daily backstop. The contract is authoritative. | None | | `liveInvoiceHashes` | function | The invoice hashes of the spends that are held, pending, paid or burned. | None | | `usedRefs` | function | Every ref the vault has used, released spends included. | None | | `ChainContext` | type | The vault's config, mandates and spends that one decision is made from. | None | ## Test doubles `@probarion/sdk/testing` holds a fake chain, a fake NEAR JSON-RPC server and a fake NEAR AI Cloud, with a ready scenario. Nothing in it talks to a real network. They are test doubles, not the contract: a test that passes against them does not show that a real vault behaves the same. To import the entry point from TypeScript, set `"moduleResolution"` to `"NodeNext"` or `"Bundler"`. | Name | Kind | What it does | Shown on | | - | - | - | - | | `FakeChain` | class | A test double of the vault, NEAR Intents and factory views, with spends, payouts and anchors. It does not model owner calls, NEAR balances or gas use. | None | | `createScenarioChain` | function | A ready `FakeChain`: the factory vault `acme.vaults.probarion.testnet` with mandate 1, which pays a NEAR account, and mandate 2, which pays a Base address. | None | | `startFakeRpcServer` | function | Serves a `FakeChain` as a NEAR JSON-RPC server on `127.0.0.1`, so the SDK's real clients and senders run against it. Returns a `FakeRpcServer`. | None | | `createFakeNearAi` | function | A fake NEAR AI Cloud: a `fetch` to hand the proposer, with a model catalog, attestation reports and signed answers, and the calls it received. | None | | `FakePanic` | class | A contract panic with a `PROBARION:` message. | None | | `FakeRawPanic` | class | A panic that is not a `PROBARION:` code, such as near-sdk's own messages. Its `code` is empty. | None | | `FakeVmError` | class | A VM failure that is not a panic, such as `Exceeded the prepaid gas.` | None | | `FakeRpcFault` | class | A JSON-RPC refusal: the node answers with an error object instead of running the call. | None | | `TEST_KEYS` | constant | Key pairs for the agent, the ledger, another key and the approver, derived from fixed public strings. Never let them hold funds. | None | | `SCENARIO_VAULT` | constant | `acme.vaults.probarion.testnet`: the scenario's vault. | None | | `SCENARIO_APPROVER` | constant | `approver.testnet`: the scenario's approver. | None | | `SCENARIO_CODE_HASH` | constant | The scenario vault's code hash, derived from a fixed string. | None | | `SCENARIO_PURPOSE_NEAR` | constant | The purpose hash of mandate 1, which pays a NEAR account. | Let an agent pay | | `SCENARIO_PURPOSE_BASE` | constant | The purpose hash of mandate 2, which pays a Base address. | None | | `SCENARIO_NEAR_PAYEE` | constant | `vendor.testnet`: the payee of mandate 1. | Let an agent pay | | `SCENARIO_BASE_ADDRESS` | constant | The Base address mandate 2 pays. | None | | `SCENARIO_NOW` | constant | The scenario's time in nanoseconds: 2026-11-05T12:00:00Z. | None | | `ZERO_CODE_HASH` | constant | `11111111111111111111111111111111`: the code hash a NEAR account without contract code reports. | None | | `GAS_FLOOR_NEAR_PAYOUT` | constant | 30 Tgas: below this prepaid gas, the fake fails a NEAR payout with `Exceeded the prepaid gas.` | None | | `GAS_FLOOR_INTENTS_PAYOUT` | constant | 115 Tgas: the same floor for a NEAR Intents payout. | None | | `FAKE_NEARAI_BASE_URL` | constant | The fake NEAR AI Cloud's base URL, on a `.test` host. | None | | `FAKE_PRIVATE_MODEL` | constant | A catalog model that is attested, verifiable and has structured outputs. | None | | `FAKE_INCOGNITO_MODEL` | constant | A catalog model with no attestation. | None | | `FAKE_NO_JSON_MODEL` | constant | An attested catalog model without structured outputs. | None | | `TEST_DOUBLE_TEE_PRIVATE_KEY` | constant | The fake TEE's signing key, derived from a fixed public string. | None | | `FakeChainInit` | type | The state a `new FakeChain()` starts from. | None | | `FakeCall` | type | One call the fake runs: signer, receiver, method, args, deposit and gas. | None | | `FakeCallResult` | type | What a call returns: the value, the vault's logs and the later receipts. | None | | `FakeReceipt` | type | One receipt after the vault's own. | None | | `FakeFactory` | type | The factory the fake serves: its versions, the vault's version and its config. | None | | `FakeKeyState` | type | An access key and its nonce. | None | | `BurnShape` | type | How the burn logs look: `mainnet`, or `stub` for the testnet stand-in. | None | | `CodeMode` | type | `local` code on the vault account, or `global` code (NEP-591). | None | | `PayoutFailure` | type | Which step of the next payout fails: `ft_transfer_failed`, `ft_withdraw_failed` or `ft_withdraw_refunded`. | None | | `ScenarioOptions` | type | The options of `createScenarioChain()`. | None | | `FakeRpcServer` | type | The running server: its `url` and `chain`, the methods it received, and `failNext`, `dropNextResponses` and `close`. | None | | `FakeRpcServerOptions` | type | How the server answers a missing access key or account: `legacy` or `structured`. | None | | `FakeNearAiOptions` | type | The options of `createFakeNearAi()`, from the model's answer to injected failures. | None | | `FakeNearAi` | type | The fake's `fetch`, and the calls it received. | None | | `FakeCatalogModel` | type | One model in the fake's catalog. | None | | `FakeChatRequest` | type | A chat request as the fake received it. | None | | `FakeSignatureStyle` | type | How the fake encodes its signature: `canonical`, `raw-recovery-id`, `high-s` or `high-s-raw-recovery-id`. | None | ## Related pages - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): the payment loop, from the instance to the outcome. - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): every status `process()` returns, and what to do next. - [Errors](https://probarion.xyz/docs/build/errors.md): the vault codes and the SDK error classes. - [Install the packages](https://probarion.xyz/docs/build/install.md): npm, a clone and the import paths. - [CLI reference](https://probarion.xyz/docs/build/cli-reference.md): the commands that print the calls an owner or an approver signs. - The source: [`packages/sdk/src/index.ts`](https://github.com/probarion/probarion/blob/main/packages/sdk/src/index.ts) and the [SDK README](https://github.com/probarion/probarion/blob/main/packages/sdk/README.md). --- # Policy rules > Every rule id decide() can record, with its effect, the vault code it mirrors, the exact condition in the code, and what to do next. The off-chain rules decide() applies before the SDK sends a spend, one row per rule id. Each row gives the rule's effect, the condition in the code that fires it, and what to do. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/policy-rules `decide()` in `@probarion/core` is Probarion's policy engine. The SDK runs it before it sends a spend, and it records one result for each rule it applies: `pass`, `hold` or `refuse`. These rules run off chain, in the process that runs the SDK. They are separate from the 14 spend checks the vault runs on chain, which have the last word: see [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md). A code hold sends nothing: the outcome is `held` with `spendId` null. Each rule has its own anchor on this page, for example [`#policy-mandate-over_total`](#policy-mandate-over_total). ## How to read the tables - **Rule id** is the `id` of one `RuleResult` in a decision's `rules`. It is also the `code` of a `refused_by_code` outcome, the first rule that refused, and of a code hold, the first rule that held. - **Effect** is what the rule does when it fires. A rule that runs and does not fire records `pass`, with a detail that says what it checked. [Which rules a decision records](#which-rules-a-decision-records) says when a rule does not run. Hold or refuse means the rule can do either. Note means the rule always passes: it only records how the decision was made. - **Vault code it mirrors** is the contract code the same condition raises in `spend`. Each code has its row on [Errors](https://probarion.xyz/docs/build/errors.md), such as [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment). "None: off-chain only" means the vault cannot check it. - **When it fires** is the condition in `packages/core/src/policy.ts`, with the code's own comparison. Amounts are integer strings in the token's smallest units. - **What to do** is the next step for whoever runs the agent. Each rule's `detail` says what it saw, such as the amount and the cap. The tables give the conditions, not the detail texts. ## The verdict One refusing rule makes the verdict `refuse`. Otherwise one holding rule makes it `hold`, and otherwise it is `allow`: `packages/core/src/policy.ts` ```ts const verdict = rules.some((r) => r.outcome === "refuse") ? "refuse" : rules.some((r) => r.outcome === "hold") ? "hold" : "allow"; ``` No rule can undo another, so a custom rule can only tighten the verdict. `decided_by` is `model+rules` only when a model proposal passed its attestation checks, which the caller reports as `attestationOk` true, and `rules-only` otherwise. `decide()` is pure: the clock, the mandates, the spends and the balances all come in through its `DecisionInput`, and the same input gives the same decision. ## Built-in rules The rows are in the order a decision records them. The rules that mirror a vault check keep the contract's order and comparisons, so the first mirrored rule that refuses names the code `spend` would panic with. The off-chain rules sit between them. | Rule id | Effect | Vault code it mirrors | When it fires | What to do | | - | - | - | - | - | | `contract.check_failed` | Refuse | `InvalidInput`, in part | `invoice.invoice_hash` is not 64 lowercase hex characters, or `ref` is not 1 to 64 bytes in UTF-8. | Fix the hash or the ref. The SDK throws `InvalidInput` for both before it decides, so through the SDK you meet this id in payment entries instead. | | `vault.frozen` | Refuse | `Frozen` | The input's `frozen` is true. The SDK reads it from the vault's config. | Find out why the owner froze the vault. Only the owner can `unfreeze` it, and then the cycle can run again. | | `model.attestation_invalid` | Note | None: off-chain only | A `proposal` came with `attestationOk` false, because its evidence failed the checks, or null, because it had none. The proposal is ignored and the decision is rules-only. `process()` never records it: only `requestSpend()` with a proposal of your own. | Nothing, for this decision. For the model to count, pass evidence made for this spend that passes the attestation checks. | | `model.unavailable` | Note | None: off-chain only | `proposal` is null and `attestationOk` is false: a model was asked and gave no answer the SDK could use. In `process()` that is a model error, a response over 64 KiB, or evidence that fails its checks at this ledger position. | Nothing, for this decision. The SDK's warning names the cause. With `model: null` the SDK decides rules-only without asking a model. | | `mandate.none` | Refuse | `NoMandate` | No mandate was chosen: a trusted proposal's `mandate_id` names no mandate in the input, or no mandate has the invoice's `purpose_hash`, or that hash is null. | Check the invoice's PO reference and the `purpose_hash` made from it. If no mandate covers this purpose, the owner creates one. | | `mandate.inactive` | Refuse | `MandateInactive` | The chosen mandate's `status` is `revoked`. A mandate that reads `exhausted` passes here and is refused by `mandate.uses_exhausted` or `mandate.over_total`. | A revoked mandate stays revoked. If the payment is still wanted, the owner creates a new mandate. | | `mandate.expired` | Refuse | `MandateExpired` | `now`, in nanoseconds, is at or past the mandate's `expires_at_ns`, or the clock or the expiry cannot be read. The status is not read: an expired mandate still reads `active`. | Mandates cannot be edited. If the payment is still wanted, the owner creates a new mandate with a later expiry. | | `mandate.agent_mismatch` | Refuse | `NotMandateAgent` | The mandate's `agent_pk` is not the input's `agentPk`, the public key of the agent key that signs the spend. | Sign with the agent key the mandate names, or have the owner create a mandate for this key. | | `invoice.po_mismatch` | Refuse | None: off-chain only | The invoice's `purpose_hash` is null, differs from the chosen mandate's, or selects another mandate than the one a trusted proposal chose. | Check the PO reference and its `purpose_hash`. When a model chose the mandate, the purpose wins. | | `mandate.payee_mismatch` | Refuse | None: off-chain only | The invoice's `payee_hint`, or the trusted proposal's, contradicts the mandate's payee. For a NEAR payee: a `near_account` or an `address` other than the mandate's own account, compared exactly, or a `chain` with no `near_account` and no `address`; the mandate's own account names no other destination, in either field and whatever the `chain`, unless it is also a Base or Solana address (an ETH-implicit `0x` account, for example), which in `address` still contradicts. For a Base or Solana payee: any `near_account`, another `chain`, or another `address`, compared without case on Base. A `name` never contradicts. | Do not pay. The invoice names a destination the mandate does not pay, and a mandate's payee never changes. If the payee really changed, the owner creates a new mandate. | | `invoice.amount_invalid` | Refuse | `ZeroAmount`, in part | `invoice.amount` is not a whole number string from 1 up to the u128 maximum, with no sign, point or leading zero, or a trusted proposal's `amount` is set and differs from it, character for character. | Write the amount in the token's smallest units: `10000000` for 10 USDC. If the model read another amount, check the invoice by hand. | | `mandate.over_per_payment` | Refuse | `OverPerPayment` | The amount is above the mandate's `max_per_payment`, or that cap cannot be read. An amount equal to the cap passes. | Do not pay it under this mandate. If the amount is right, the owner creates a mandate with a higher cap. | | `mandate.uses_exhausted` | Refuse | `UsesExhausted` | The mandate's `uses` is not below its `max_uses`, which includes a count that is not a number. | Wait for a held spend to be rejected or to expire, which gives its use back, or have the owner create a new mandate. | | `mandate.over_total` | Refuse | `OverMandateTotal` | `spent` plus `reserved` plus the amount is above `max_total`, or one of the three cannot be read. The sum stops at the u128 maximum, as the contract's does. | Wait for a held spend to be rejected or to expire, which frees its reservation, or have the owner create a new mandate. | | `invoice.duplicate` | Refuse | `DuplicateInvoice`, `DuplicateRef` | The invoice hash is in `paidInvoiceHashes`, checked first, or the ref is in `usedRefs`. The SDK fills them from the vault's spends: the invoices of held, pending, paid and burned spends, and the ref of every spend. | Read the detail. When it says the invoice "was already paid or is pending", do not pay it again. When it says the ref "was already used", run the cycle again under a new ref. | | `vault.daily_backstop` | Refuse | `OverDailyBackstop` | `daily.spent_today` plus the amount is above `daily.backstop`, or either cannot be read. The SDK passes the vault's backstop and the total of its live spends created on the UTC day of `now`. | Wait for the next UTC day. The backstop is one limit for the whole vault, set when the vault is created. | | `intents.balance_low` | Hold | None: off-chain only | The mandate pays a Base or Solana address through NEAR Intents, and `intentsBalance` is null or below the amount. The SDK reads the vault's balance of the mandate's asset inside NEAR Intents, and a failed read gives null. | The owner tops up the vault's balance of that asset inside NEAR Intents, then the cycle runs again. A NEAR payee never holds here. | | `model.refused` | Hold or refuse | None: off-chain only | A trusted proposal's `action` is `hold`, which holds, or `refuse` or any value other than `pay`, which refuses. `pay` passes. | Check the invoice yourself. For `hold` and `refuse` the detail quotes the model's reasoning, cut at 200 characters. For another action it names that action. | | `model.low_confidence` | Hold | None: off-chain only | A trusted proposal's `confidence` is not at or above `confidenceFloor`: 0.7 by default (`DEFAULT_CONFIDENCE_FLOOR`), or the SDK's `confidenceFloor` option, from 0 to 1. A confidence that is not a number holds. | Check the invoice yourself before you run the cycle again, which asks the model again. | | `custom.` | Hold or refuse | None: off-chain only | Your rule's `check(input, chosen)` returned `hold` or `refuse`. It also refuses when its id does not start with `custom.`, its outcome is not `pass`, `hold` or `refuse`, or `check` throws. The result is recorded under the rule's own `id`, whatever id `check` returns. | Your own code decided: read its detail. | > **Owner signs** > > Some fixes in the What to do column are the owner's own transactions, such as `unfreeze` and a new mandate with `create_mandate`. The owner signs each one from their own wallet. The CLI's `vault unfreeze` and `mandate create` print the transaction and never sign it. > > _The owner signs this transaction. Probarion never signs it._ ## Which rules a decision records A decision does not record every rule every time: - `contract.check_failed` is recorded only when it refuses. - At most one of `model.attestation_invalid` and `model.unavailable` is recorded. `model.refused` and `model.low_confidence` are recorded only for a proposal that passed its attestation checks. - With no mandate chosen, `mandate.inactive`, `mandate.expired`, `mandate.agent_mismatch`, `invoice.po_mismatch`, `mandate.payee_mismatch`, the three limit rules and `intents.balance_low` are left out. The three limit rules are `mandate.over_per_payment`, `mandate.uses_exhausted` and `mandate.over_total`. - With an invalid amount, the three limit rules and `intents.balance_low` are left out, and `vault.daily_backstop` passes as not evaluated. - With `daily` null, `vault.daily_backstop` passes as not evaluated. Through the SDK `daily` is never null, so there the backstop is evaluated whenever the amount is valid. - Custom rules come last, in the order they were added. ## How decide() picks the mandate The decision is about one mandate, chosen before the mandate rules run: 1. A trusted proposal's `mandate_id` names it. A proposal is trusted only when `attestationOk` is true. 2. Otherwise `purposeMatch()` takes the mandates whose `purpose_hash` equals the invoice's, lowest id first, and picks the first one the vault could still take a spend from at `now`: not revoked, not expired, bound to this agent's key, with a use left and room under `max_total` for the smallest amount, 1. 3. If none of them qualifies, it picks the lowest-id match anyway, so the decision refuses with the precise reason, such as `mandate.expired`, instead of `mandate.none`. The [`invoice.po_mismatch`](#policy-invoice-po_mismatch) row says when the chosen mandate and the purpose disagree. `purposeMatch()` is exported from `@probarion/core`, and `process()` uses it to pick the one mandate it shows the model. `requestSpend()` decides against the mandate you name and nothing else. ## From a verdict to an outcome `process()` and `requestSpend()` write the decision entry to the ledger first, then act on the verdict: - **`refuse`.** Nothing is sent. The outcome is `refused_by_code`, with `spendId` null and `code` set to the first rule that refused. - **`hold`.** The SDK asks the vault's free `check_spend` dry run. If the vault would pay at once, the SDK sends nothing: the outcome is `held` with `spendId` null, and `code` is the first rule that held. This is a code hold. No spend is on chain, so no approver can act on it and no call releases it: change what caused it, then run the cycle again. If the vault would hold the spend too, the SDK sends it and the vault holds it for an approver, so the outcome is `held` with a `spendId`. - **`allow`.** The SDK asks `check_spend`, then sends `spend` once. When `check_spend` refuses a decision that code allowed or held, the outcome is `refused_by_contract` with the bare vault code, and nothing is sent. [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) covers every status, and [Code hold](https://probarion.xyz/docs/build/spend-outcomes.md#code-hold) what to do about each holding rule. ## Your own rules `probarion.rules.add(rule)` adds a `CustomRule` to every later decision of that instance: `process()`, and `requestSpend()` unless `skipPreflight` is true. A `CustomRule` is an `id` that starts with `custom.` and a `check(input, chosen)` function, which gets the same `DecisionInput` as the built-in rules and the chosen mandate, or null. `rules.add()` throws `ConfigError` for an id that does not start with `custom.` or is only `custom.`, and for a rule with no `check()`. The SDK's integration test adds a rule that refuses every invoice: `packages/sdk/test/probarion.integration.test.ts` ```ts pb.rules.add({ id: "custom.no_payments_today", check: () => ({ id: "custom.no_payments_today", outcome: "refuse", detail: "payments paused", }), }); const out = await pb.process(nearInvoice("Invoice INV-1 for PO-1001", "INV-1")); expect(out).toMatchObject({ status: "refused_by_code", code: "custom.no_payments_today" }); ``` The [`custom.`](#policy-custom) row says how a custom rule's result is recorded, and what turns it into a refusal. ## Rule ids recorded outside decide() The SDK and Probarion's own hosted cycles record these ids as well. They use the same `RuleResult` shape, so they appear in the same `rules` lists of decision and payment entries. | Rule id | Effect | Recorded by | When | What to do | | - | - | - | - | - | | `ledger.unresolved` | Refuse | The SDK, in the decision entry, after `decide()` | An earlier payment entry of this invoice in the same ledger has the status `unknown`, and none of these holds yet: a later payment entry of that decision knows its status, a spend on chain carries the decision's hash, or 48 hours (`UNKNOWN_SETTLE_MS`) have passed since that entry. It refuses in raw mode too. | Never send it again. Find the spend by its ref in the vault's spends, or wait until the time the detail names. | | `contract.check_failed` | Refuse | The SDK, in the payment entry | `check_spend` refused, `spend` panicked, or the spend failed without the vault releasing it: a transaction that did not run, or a spend receipt that failed without a vault code. The detail says which. | Read the payment entry's `contract.code` and look it up on the Errors page. | | `invoice.duplicate` | Note | The SDK, in the payment entry | The vault answered `DuplicateRef` or `DuplicateInvoice` to a re-signed copy of this spend, and the SDK found this decision's own spend by its ref with the status `paid`, `burned` or `held`. The rule passes and names that spend. | Nothing: the outcome reports that spend as it stands. | | `custom.raw_mode` | Note | The SDK, as the first rule of a raw-mode decision | `requestSpend()` ran with `skipPreflight: true`, which is meant for the Canary only. The code rules and `check_spend` are skipped and `spend` is always sent. Of the code rules, only `ledger.unresolved` can still refuse. | Nothing: the vault alone decides. A panic comes back as `refused_by_contract` with the failed transaction's hash. | | `custom.invoice_number_duplicate` | Refuse | Payables (Live on testnet), as the first custom rule of the cycle, before `custom.owner_confirms_without_model` | Another invoice to the same payee has the same invoice number, and it may have paid or may still pay: it is processing, sent and not yet confirmed, waiting for the owner, or decided as paid, burned, settled, held on chain with a spend, or refused because its own text was already paid or may still pay (`invoice.duplicate` or `ledger.unresolved` in its decision, or the contract's `DuplicateInvoice` or `DuplicateRef`). The texts differ: the same text is refused at intake as a duplicate, with no cycle. The detail names the earlier invoice by its invoice hash, not by its number, and names one that may have paid before one that waits for the owner. When a built-in rule of `decide()` refuses first, the outcome's code is that rule's, and the refusal is final like any other. | Nothing was sent. Look at the earlier invoice the detail names. If it waits for the owner, upload this one again once the owner rejects it in the console, and any other invoice with this number that waits for them: it runs on the same row and ref. If the console shows this invoice as "Outcome unknown: do not resend", its refusal could not be written, and the number stays blocked whatever happens to the earlier invoice. Otherwise the number stays blocked while that invoice may still pay, and a paid invoice is not paid again, even when an agent paid its text outside the console and the console refused it as already paid. The console does not yet free the number of an invoice held on chain, sent and not confirmed, or with an unknown outcome when its spend fails, is rejected or expires. | | `custom.owner_confirms_without_model` | Hold or refuse | Payables, the console's reference agent (Live on testnet), on every cycle but the owner's confirmed one | No attested model stated the amount the code read: no proposal, one that is not attested, or a model amount that is null or differs. It holds, or refuses when the amount is above the mandate's `approval_above` or cannot be compared, since a hold would then send the spend for approval. | The vault's owner confirms the amount in the console, on the invoice's "Waiting for you" card. | | `custom.owner_confirmed` | Refuse | Payables (Live on testnet), in the cycle that runs after the owner confirms | The decision does not pay exactly what the owner signed for: another amount, invoice hash, mandate or payee, or no mandate. When it passes, its detail holds the owner's whole signed confirmation. | Nothing was sent. Compare the confirmed amount, invoice and mandate with the decision's. | | `custom.canary_no_autopay` | Refuse | The Canary, Probarion's public-challenge vault (Live on testnet), on every cycle | Always: the Canary never pays from its own cycle. It is the outcome's `code` only when no built-in rule refused first. | Nothing: it is the expected refusal there. | The four SDK rows can appear in the ledger of any agent that uses the SDK. The other three come from Probarion's hosted cycles, so they appear only in the ledger of a vault that runs one. [The 48-hour guard](https://probarion.xyz/docs/build/spend-outcomes.md#the-48-hour-guard) explains `ledger.unresolved` in full. ## What the rules do not check - `approval_above`. No rule of `decide()` reads it: the vault holds a spend above it, after the SDK sends the spend. Of the rules recorded outside `decide()`, only [`custom.owner_confirms_without_model`](#policy-added-custom-owner_confirms_without_model) reads it. - The vault's USDC balance. A spend the vault cannot cover passes every rule, and its transfer fails, as [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) shows. - Whether the signer is one of the vault's agent keys. `decide()` takes `agentPk` as given, so the contract's [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent) has no rule. - The `decision_hash` part of [`InvalidInput`](https://probarion.xyz/docs/build/errors.md#InvalidInput). The decision hash does not exist yet when `decide()` runs. - A change on chain after the SDK read the vault. The vault runs its own checks on every spend, and its answer stands. The rules are the same on both networks. Today the vaults run on NEAR testnet, and one small vault, `v0.probarion.near`, runs on mainnet: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). ## Related pages - [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): what each outcome means and what to do next. - [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks a spend meets inside the vault. - [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): a code hold next to a contract hold. - [Mandates](https://probarion.xyz/docs/concepts/mandates.md): the mandate fields these rules read. - [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md): why only an off-chain rule can refuse a contradicting payee. - [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle from your own code. - [Errors](https://probarion.xyz/docs/build/errors.md): every vault code and SDK error. --- # CLI reference > Every probarion CLI command and option, with required flags, defaults, who signs each printed transaction, its gas and deposit, and the exit codes. Every command and option of the probarion CLI, with who signs what each command prints. The CLI never signs: it prints a near-cli-rs command for the owner or an approver to sign. Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5 Canonical page: https://probarion.xyz/docs/build/cli-reference The `probarion` CLI dry-runs a spend, verifies a vault, exports the ledger, generates keys and prints the transactions that a vault's owner and approvers sign. It signs nothing itself: for each owner or approver call it checks your input against the contract's rules, writes the arguments to a file and prints the near-cli-rs command for that person to sign. The tables below list every command and option of the CLI, version 0.1.1, the release on npm, as `packages/cli/src/program.ts` builds it, and a test fails when the code gains or loses a command or an option that they do not list. ## How to read the tables Each table holds one command group, and each row one command. A row's anchor is its command with `cli-` in front and hyphens for spaces: `vault create` is at [`#cli-vault-create`](#cli-vault-create). - **Command** is what follows the program name. Only `help` takes a positional argument, the command whose help it prints: every other input is an option. In your own project folder, run a command as `npx probarion vault create ...`, or as `probarion vault create ...` after `npm install -g probarion`. In a clone of the repository, run the CLI the clone builds, as `node packages/cli/dist/bin.js vault create ...`. [Install the packages](https://probarion.xyz/docs/build/install.md) has both. - **Options** lists every option of the command, spelled as the CLI's help spells it. "(required)" means the command stops with an error without that option. "(default `x`)" is the value the option takes when you leave it out. Every command but `help` also takes `--help`. - **Who signs** names the account whose wallet signs the printed near-cli-rs command, with the prepaid gas and the deposit that command attaches. "Nothing" means the command prints no transaction. The placeholders in the options mean: - ``: an amount in USDC with up to 6 decimals, so `--amount 25` is 25 USDC. - ``: an amount in NEAR with up to 24 decimals. - ``: after `--vault`, a NEAR account id. After `--mandate` or `--spend`, a whole number: ids start at 1. - ``: an ed25519 public key, written `ed25519:...`. - ``: a NEAR account id. ``: account ids separated by commas. Three options recur. `--vault ` falls back to `PROBARION_VAULT_ID`, and every command that has the option needs one of the two, except `verify --offline`, which refuses it. `--sign-as ` writes the signer into the printed command. Without it, the command holds a placeholder such as ``, which the shell refuses as pasted. `--out-dir ` is the folder for the arguments file. [Configure](https://probarion.xyz/docs/build/install.md#configure) lists every environment variable the CLI reads. ## Program options These options belong to the program, not to one command, so each appears here and in no command row. They can come before the command words, as in `npx probarion --network testnet vault freeze --vault `, or after them. NEAR testnet is live today. On mainnet one small vault runs, `v0.probarion.near`, and no mainnet factory is deployed: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md). | Option | When not given | What it does | | - | - | - | | `--network ` | `NEAR_NETWORK`, else `testnet` | The network, `mainnet` or `testnet`. Any other value is an error. A printed call carries it as `network-config`. `agent keygen` and `ledger export` do not read it, and `verify --offline` refuses it. | | `--rpc ` | `NEAR_RPC_URLS`, else the network's default RPCs | Comma-separated NEAR RPC URLs. Only the commands that read the chain use them: `vault create`, `check` and `verify`. An empty list is an error, and `verify --offline` refuses it. | | `-V`, `--version` | None | Prints the CLI's version, `0.1.1`, and exits 0. | | `-h`, `--help` | None | Prints the help of the program, or of the command it follows, and exits 0. The help of `check` and `verify` also lists their exit codes. | ## What a signing command prints > **Owner signs** > > The command in this section prints a call for the owner to sign. Review the arguments file and its sha256 first, then sign with your own wallet, keychain or Ledger. > > _The owner signs this transaction. Probarion never signs it._ Run in your own project folder, this command prints the `revoke_mandate` call for mandate 1 of an illustrative testnet vault. It reads nothing from the chain, and it writes the arguments file into the folder: ```bash npx probarion --network testnet mandate revoke --vault acme.vaults.probarion.testnet --mandate 1 ``` ```text OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs: near contract call-function as-transaction acme.vaults.probarion.testnet revoke_mandate file-args ./revoke_mandate.0a38f6055d2b.args.json prepaid-gas '30.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as network-config testnet sign-with-keychain send sha256 of the args file: 0a38f6055d2b1a7cf20555fde86bb075f5aee4957da14266e1982f4ed8200069 (check: sha256sum ./revoke_mandate.0a38f6055d2b.args.json, or Get-FileHash -Algorithm SHA256 ./revoke_mandate.0a38f6055d2b.args.json in PowerShell) Run it in bash, zsh or PowerShell (not cmd.exe). revoke_mandate arguments (./revoke_mandate.0a38f6055d2b.args.json): { "mandate_id": 1 } Pending spends keep their reservation until they settle or fail; approve then refuses with MandateInactive. With a Ledger hardware wallet, replace `sign-with-keychain send` with `sign-with-ledger usb send`. ``` Every command that prints a call prints these parts: - **Who signs.** The first line starts with `OWNER SIGNS` or, for `spend approve` and `spend reject`, `APPROVER SIGNS`. - **The arguments file**, named `..args.json`. The name follows the content, so a command you printed earlier keeps pointing at the arguments you reviewed. The CLI refuses to print over a file of that name whose content differs. - **The sha256 of that file**, to check before you sign, with `sha256sum` or, in PowerShell, `Get-FileHash`. - **The near-cli-rs command.** It ends with `sign-with-keychain send`, and runs in bash, zsh or PowerShell, not in cmd.exe. - **A note** on what the call does once signed, from the same rules as the table's "What it does". Before it prints, the CLI checks your input against the contract's rules. An input it refuses stops it with `error:` on stderr and exit code 1, and it writes no file. ## Vault commands > **Owner signs** > > Every vault command prints a call the owner signs. [Vaults, roles and keys](https://probarion.xyz/docs/concepts/vaults-roles-and-keys.md) explains the owner's role. > > _The owner signs this transaction. Probarion never signs it._ | Command | Options | What it does | Who signs | | - | - | - | - | | `vault create` | `--name ` (required), `--approvers ` (required), `--daily-backstop ` (default `500`), `--held-ttl-hours ` (default `72`), `--deposit `, `--sign-as `, `--out-dir ` (default `.`) | Reads the factory's current version and `min_deposit`, then prints the factory's `create_vault` call for the vault `.`. The name is 2 to 31 characters of `a-z`, `0-9`, `_` and `-`, starts with a letter or digit, and must make `.` a valid NEAR account id: at most 64 characters in all, no `-` or `_` at the end of the name, and never two of them in a row. It takes 1 to 10 distinct approvers. The daily backstop is the most the vault reserves or pays in one UTC day, and a hold lasts 1 to 720 hours. `--deposit` is required when the factory's current version is embedded. The factory is `PROBARION_FACTORY_ID`, else the network's. | The owner: the account that signs becomes the vault's owner. `300.0 Tgas`, and a deposit of `--deposit` NEAR, else the factory's `min_deposit`, which stays in the vault for storage and gas. | | `vault freeze` | `--vault `, `--sign-as `, `--out-dir ` (default `.`) | Prints the vault's `freeze` call. Until `unfreeze`, the vault refuses every new spend and every approve. `reject` and `expire_held` still work. | The owner. `30.0 Tgas` and no deposit, so a full-access key, or a function-call key of the owner's account that may call this vault, can sign it. | | `vault unfreeze` | `--vault `, `--sign-as `, `--out-dir ` (default `.`) | Prints the vault's `unfreeze` call, which lets spends and approvals run again. | The owner. `30.0 Tgas` and `1 yoctoNEAR`, so only a full-access key, through a wallet prompt, can sign it. | | `vault withdraw` | `--vault `, `--amount ` (required), `--sign-as `, `--out-dir ` (default `.`) | Prints the vault's `withdraw` call, which sends native USDC from the vault to the vault owner's account and nowhere else. | The owner. `100.0 Tgas` and `1 yoctoNEAR`. | | `vault withdraw-intents` | `--vault `, `--token ` (required), `--amount ` (required), `--sign-as `, `--out-dir ` (default `.`) | Prints the vault's `withdraw_intents` call: the intents contract sends the vault's USDC balance inside NEAR Intents to the owner's account and nowhere else. `--token` is `base`, `solana` or one of the network's two allowed USDC asset ids. | The owner. `150.0 Tgas` and `1 yoctoNEAR`. | ## Mandate commands > **Owner signs** > > Both mandate commands print a call the owner signs. A mandate has no edit call: to change one, revoke it and create a new one. [Mandates](https://probarion.xyz/docs/concepts/mandates.md) explains each limit. > > _The owner signs this transaction. Probarion never signs it._ | Command | Options | What it does | Who signs | | - | - | - | - | | `mandate create` | `--vault `, `--payee ` (required), `--max-per ` (required), `--max-total ` (required), `--uses ` (required), `--expires ` (required), `--approval-above ` (required), `--agent-pk ` (required), `--purpose ` (required), `--label