The claim this document defends: you can hand an untrusted agent the ability to close your position without handing it the ability to take your money. That is not a promise about the agent's behaviour. It is a property of the contract and this document says exactly where it is enforced and how it is proven.
The interesting adversary is not an outsider. It is the agent you deliberately gave a key to.
Assume the worst about it: the agent is malicious or it was compromised or its key leaked or the model driving it was manipulated by a prompt injection in some market's question text. Assume it will sign anything, at any time, naming any recipient it likes.
What can it do?
It can close a position it was granted a mandate over, once the market has actually reached a price
the owner named, paying the address the owner pinned. That is all. It cannot do the following, and
each item is checked on-chain in cairo/src/leveraged_market.cairo:
| It cannot | Because |
|---|---|
| redirect a payout | do_agent_close reads payout_target from storage, never from the agent's input. The agent's calldata carries no recipient at all. |
| act outside the granted band | the contract compares the live marginal price to stop_price_bps and take_price_bps and reverts with MANDATE_NOT_MET. |
| widen its own authority | a mandate is written once, inside do_open. There is no setter and re-opening the same key reverts with POSITION_EXISTS. |
| touch a self-managed position | a zeroed agent_key means no agent close can pass. It reverts with NO_MANDATE. |
| impersonate the owner | the owner path verifies against position_key, the agent path against agent_key. Neither signature verifies on the other path. |
| drain the contract | reserves are backed one for one by tokens held in the contract. Leverage is a vault risk, bounded by liquidation and insurance, not a contract-solvency risk. |
So the worst case for a stolen agent key is that somebody else fires a stop the owner had already asked for, at a price the market had actually reached, sending the money to the owner's own address. The owner loses the option value of choosing the moment. They do not lose funds.
That is the whole point of the design. An agent key is safe to hand out, which is what makes autonomous delegation possible at all.
Confusing these is how custody accidents happen, so they are named separately everywhere in the code.
The owner position key. A fresh Stark keypair minted per position. The public half is the position's on-chain identity; the private half is a bearer instrument that can close the position on any terms and collect the equity. Whoever holds it owns the position outright.
An agent must never hold one. assertNotOwnerKey in agent/src/keys.mjs refuses any argument that
looks like a private key, with an error explaining why, so a caller that tries is stopped rather than
quietly trusted. The lev-close command reads a coupon from a file rather than an argument, so a
private key never lands in shell history or a process list.
If you lose it, the margin is stranded. If you copy it, the copy can close the position. Back it up the way you would back up cash.
The agent key. Generated by veilcast-agent init into .veilcast/agent-key.json at mode 0600.
This is the key the whole trust model is built to make harmless. doctor flags it if the file mode
lets anyone else read it.
Rotating it (init --rotate) makes every live mandate naming the old key unfireable, so those
positions become owner-only. That is a safe failure, not a loss.
The funding account key. Pays gas and submits transactions. Read from an sncast accounts file the operator points at. The runtime never generates it, never copies it and never writes to that file. It is the one key with real spending power, so keep the balance thin: a keeper needs gas plus the margin it intends to post and nothing more.
Not in the CLI, not in the skill files, not in a prompt. In the contract.
do_agent_close in cairo/src/leveraged_market.cairo performs, in order:
- the position must be open, else
NO_POSITION - a mandate must exist naming an agent, else
NO_MANDATE - the signature must verify against the stored
agent_keyover the storedpayout_target, elseBAD_CLOSE_SIGNATURE - the live marginal price must be at or below the stop or at or above the take, else
MANDATE_NOT_MET - the payout goes to
mandate.payout_target, read from storage
The agent's input is six felts: an action index, the market, the side, the position key and a signature. There is nowhere in it to put a recipient or a price. This is deliberate: a field an agent could fill is a field an agent could abuse, so the fields that matter are not in the input at all.
The guardrails in the CLI and the skill packs exist to keep an honest agent from wasting gas on a call the contract would refuse. They are a convenience. Deleting all of them would not weaken the security property by one felt.
Not asserted. Fuzzed, in cairo/src/tests/test_leverage.cairo, with 66 tests green and zero
warnings.
| Test | What it proves |
|---|---|
fuzz_a_stranger_key_never_closes_a_mandated_position |
40 random keys, band already met, signing over varied targets. Every one is refused. |
fuzz_the_real_agent_cannot_act_outside_its_band |
40 random bands away from the price. The legitimate agent is refused every time. |
fuzz_an_agent_close_always_pays_the_pinned_target |
40 random sizes. The pinned address is always paid and no other address ever receives anything. |
fuzz_owner_and_agent_signatures_never_swap |
Both directions. Neither signature verifies on the other path. |
fuzz_agent_and_keeper_paths_stay_solvent |
Random interleavings of agent close and keeper liquidation. The balance invariant holds throughout. |
a_mandate_cannot_be_changed_after_the_open |
Re-opening with a wider mandate is refused and the original is intact. |
a_mandate_must_be_well_formed |
A mandate with no payout address or no band at all, is refused at the open rather than stored as a trap. |
a_self_managed_position_admits_no_agent |
No mandate means no agent close, whatever is signed. |
the_owner_can_still_close_a_delegated_position |
Delegation adds a hand; it does not remove the owner's. |
The solvency invariant, balance >= vault_free + total_backing + insurance, is asserted on every path
in every one of these and separately fuzzed over random open, close and liquidate sequences.
Four independent implementations of the close-message hash (Cairo, the SDK, the app, the agent runtime) are pinned to one hardcoded felt in four test suites, so a layout drift in any of them fails a test rather than reverting a live transaction with a bad signature.
STRK20 gives identity privacy, not amount privacy. Overclaiming this would be wrong, so the code, the docs and the generated skill files all state it the same way and a test asserts that none of the generated documents ever describe amounts as private.
Private: who opened or closed a position. The contract is never told an address and the on-chain sender is the pool's rotating relayer. The link between two positions by one person, because every position is keyed by a fresh coupon. The link between a position and the wallet that eventually collects.
Public: every amount. Margins, notionals, volumes, prices, the vault's state. Liquidity provision and liquidation, which are infrastructure rather than trades. A shield deposit, which is a screened public transaction into the pool.
Amounts are public on purpose. A prediction market with hidden sizes cannot produce accurate odds and accurate odds are the product.
These are real and not mitigated by anything above.
The owner's coupon is a bearer instrument. Lose it and the margin is stranded. Leak it and whoever has it can close the position and take the equity. No contract change can fix this; it is what makes the position unlinkable.
A mandate cannot be revoked. There is no setter, which is what stops an authority being widened, but it also means an owner cannot narrow or cancel one after the open. To end a delegation, close the position. Grant bands you are willing to live with for the life of the position.
The agent chooses the moment inside the band. Once the price crosses the stop or the take, the agent decides when to fire and prices move. It cannot fire outside the band, but within it the exact execution price is its choice. Narrow bands narrow that discretion.
The vault bears leverage risk. Liquidation and the insurance fund bound it, but a fast enough
crash can leave bad debt that exceeds insurance and that lands on liquidity providers. bad_debt is
reported on every PositionLiquidated event, so it is observable rather than hidden.
Liquidation depends on somebody watching. The contract cannot liquidate itself. The 1% keeper
reward is the incentive and --min-reward means a rational keeper skips positions that do not cover
gas. A position that is underwater but unprofitable to liquidate can sit there.
The proving and discovery services are third-party infrastructure. They take no API key, which is what makes headless operation possible, but they are not ours and could change or go away. Nothing in the contracts depends on them; only the ability to submit a pool action privately does.
The payout leg is blocked on mainnet today. An SDK proof-version mismatch, described in
OPERATIONS.md. It affects the whole program rather than just Veilcast. It is upstream of us.
Found something? Open an issue at github.com/zkasuran/veilcast. If it is a live-funds
vulnerability, say so in the title and do not include an exploit in the public body.