Your AI agent can spend.
It can’t steal.

Leash keeps an agent’s budget in a NEAR smart contract. The agent’s key can only ask to spend. Caps, trusted receivers and owner approval are enforced on-chain, so even a jailbroken agent cannot drain you.

Agent key
1 method
Policy
In the contract
Trusts the model
Never
Agent budget on NEAR testnet
…NEAR
available to the agent, held by
vault.leash-gv2adb.testnet
Spent today without asking0 / … NEAR
Paid automatically
up to … NEAR each, … NEAR a day
Trusted receivers
…
Always refused
…
Everything else
waits for the owner to approve
Ask the vault first
free dry run, check_spend(), nothing is sent
The same rules request_spend runs, as a free view call.
The agent's only key
verify on explorer
…
full-access keys
…()
the one method it can call

It cannot transfer NEAR, add keys, approve, or touch the policy. A stolen or jailbroken agent can only ask.

Owner …

Errand, a shopping agent with a Leash wallet

Give it a task, or try to make it steal. Every payment is a real testnet transaction.

Pick a scenario below. The first is normal use; the rest are real attacks on a real wallet.

Then flip Jailbreak the agent and run them again. The agent will try to pay. Watch the contract say no.

Vault ledger

Every request the agent made, read from the contract. Refusals are recorded too.

Try to steal it.

Get one payment to drainer.leash-gv2adb.testnet. Flip the jailbreak, paste a poisoned invoice, write your own attack. Every refusal is recorded on-chain and counted by the contract itself.

Keep at it and the tripwire answers: repeated refusals and the vault freezes itself, even for the agent’s normal purchases.

Open the agent
requests refused
…
NEAR refused
…
tripwire freezes
…
payments made
…

Every one of the … payments was inside the policy or approved by the owner. Counted by the vault contract in get_vault().stats.

latest refusals

Reading the vault…

How it works

Prompts can be broken. Contracts can’t be talked out of their rules.

Most agent wallets put the spending rules in the agent’s own code, the same code an attacker is trying to talk into misbehaving. Leash moves the rules to where the money is.

  1. 01

    The agent decides to pay

    Any model, any prompt. Assume it can be fooled by an invoice, a web page or a direct jailbreak. Leash does not trust it.

    any model, untrusted
  2. 02

    The agent checks the receiver

    Allowlist, lookalike names (cafee vs cafe), whether the account exists, whether it is a contract, past payments. It refuses to sign on a block. Useful, but a compromised agent can skip it.

    off-chain guard
  3. 03cannot be skipped

    The vault contract decides

    Frozen? Blocklisted? Over the per-payment or daily cap? Unknown receiver? The rules run on-chain. The agent's function-call key can reach request_spend() and nothing else.

    Rust contract, function-call key
  4. 04

    The owner has the last word

    Anything outside the policy waits for the owner, who approves or denies from any NEAR wallet, or freezes the agent in one transaction.

    NEAR wallet
Chain Signatures

One leash, any chain.

The vault also controls an Ethereum Sepolia address. Its private key does not exist anywhere: NEAR’s MPC network signs for it, and only when the vault contract asks. So the policy that guards the agent’s NEAR guards its ETH too, with no bridge and no second wallet to secure.

Try “Pay on Ethereum” and “Drain it on Ethereum”
  1. 01

    The agent asks, same as always

    request_spend(receiver: 0x…, amount, chain: "ethereum"). Same method, same function-call key. The agent never sees an Ethereum key.

  2. 02

    The vault decides and builds the transaction

    Same rules: caps in ETH, allowlist, blocklist, owner approval, tripwire. If it passes, the contract writes the EIP-1559 transaction itself (nonce, fee ceiling, receiver, amount) and hashes it.

  3. 03

    NEAR's MPC network signs that hash

    The vault calls v1.signer-prod.testnet. The signature is for an address derived from the vault's own account, so nobody else can ask for it. Anyone can broadcast the result; nobody can change it.

I stole the agent’s key and tried to drain the vault.

On 6 November 2026 I ran npm run attack with the agent’s only key against this vault. Nine attempts. The vault had 9.953 NEAR before and 9.947 after; the difference is gas. Nothing reached the attacker.

The first five never reach the contract: NEAR rejects them because the key is a function-call key scoped to one method. The last four reach the contract, which refuses them and records why. The agent above is a deliberately gullible script, the worst case for a wallet. Swap in a real model on NEAR AI Cloud and none of this changes, because none of it lives in the model.

Full log in the repo
around the vault: rejected by the protocol
  • Send NEAR straight out of the agent accountneeds a full-access key, the agent has none
  • Add a full-access key to the agent accountneeds a full-access key, the agent has none
  • Call vault.withdraw()the key may only call request_spend()
  • Approve its own held request with vault.approve()the key may only call request_spend()
  • Add the attacker with vault.allow()the key may only call request_spend()
through the vault: refused by the contract, on-chain
  • 1.5 NEAR to the known drainerBlocked: receiver is blocklisted tx
  • 0.45 NEAR to cafee, a lookalike of cafeHeld: receiver not on allowlist tx
  • 2 NEAR to cafe, a trusted merchantHeld: over the 0.5 NEAR per-payment cap tx
  • 500 NEAR to cafeBlocked: more than the vault holds tx