Sessions
A session lets an agent trade without its owner's main key online. The owner signs once: the limits, the tokens, the risks the agent may accept, and an expiry. After that a separate executor key trades inside those limits, and each trade also needs Orblivion's co-signature for exactly that one call.
How a session works#
Three keys take part, and no single one of them can move funds:
| Key | Who holds it | What it does |
|---|---|---|
| The main key | the wallet's owner | signs the session once (a delegation, and on a plain wallet an EIP-7702 authorization), and can revoke it on chain at any time |
| The executor | the agent: its own hot key, or for a hosted agent one Orblivion's signing service holds for it | sends each trade |
| The co-signer | Orblivion's separate signing service | co-signs each trade after its own checks, for exactly one call |
It uses MetaMask's audited Delegation Framework v1.3.0, deployed on Robinhood Chain:
- The root. The owner's wallet delegates to the co-signer, with caveats that the chain's DelegationManager enforces on every trade (below). Its salt is the hash of every limit the owner chose, so the signature binds them.
- The leaf. For each trade, the co-signer delegates onward to the executor, allowing exactly one call (this router call, this value, this calldata), once, for at most two minutes.
- The trade. The executor sends
redeemDelegations([leaf, root])to the DelegationManager, which runs every caveat and then has the wallet call the Universal Router.
The wallet must run MetaMask's EIP7702StatelessDeleGator v1.3.0 for this. A plain wallet (an EOA) gets it through an EIP-7702 authorization the owner signs at the grant. The key stays the owner: the wallet keeps working as before, and one authorization to address 0 resets it.
What the owner signs#
The root's caveats#
| Caveat | Enforces, on chain |
|---|---|
| AllowedTargets | the Universal Router is the only contract the session can call |
| AllowedMethods | only execute(bytes,bytes[],uint256) |
| ValueLte | at most this much ETH per trade (the per-trade cap, in wei at the grant's ETH price) |
| NativeTokenPeriodTransfer | at most this much ETH per 24-hour window (sessions that trade ETH) |
| Timestamp | nothing after the expiry |
| LimitedCalls | at most this many trades |
| Redeemer | only the executor can redeem |
The binding#
The root's salt is the hash of the session's limits, its binding. Every new grant is version 4, which binds:
- the wallet, the executor and the expiry;
- the tokens, and each token's per-trade cap in its smallest unit;
- the dollar caps per trade and per day, and the number of trades;
- the risk terms (below): the severe risks the session may trade with, and Dossier Shield and its policies;
- the fees that go with its Shield mode (
fees, see Fees); - a random nonce.
The SDKs rebuild the binding from what the owner asked for and refuse a grant whose binding, caps or caveats differ (GRANT_BINDING, GRANT_LIMITS, GRANT_CAPS, CAVEAT_*), before anything is signed. They price each token's cap themselves and refuse one more than 2% (plus the token's own fees) away from their own price. They also refuse a grant that names any co-signer other than the one they pin.
The authorization, on a plain wallet#
A plain wallet needs an EIP-7702 authorization to EIP7702StatelessDeleGator v1.3.0. This changes the wallet's code, which is the owner's decision: the SDKs sign it only with allow7702: true (TypeScript) or allow_7702=True (Python), and otherwise refuse with CONSENT_REQUIRED. The executor's first trade carries it as a type-4 transaction, so the main key sends nothing. The authorization is tied to the wallet's nonce: any transaction from the wallet after the grant makes it stale, and the grant has to run again.
The setup, for token inputs#
ERC-20 tokens leave the wallet only through Permit2, so a session that sells a token needs a one-time setup the main key sends: approve(Permit2, budget) and Permit2.approve(token, router, budget, expiry), each exactly the session's budget. That budget caps every ERC-20 the session can spend, in total, until it expires. ETH needs no setup.
Grant a session#
import { createOrblivionClient } from '@orblivion/sdk'
import { privateKeyToAccount } from 'viem/accounts'
const owner = createOrblivionClient({ account: privateKeyToAccount(OWNER_KEY) }) // the main key
const executor = createOrblivionClient({ account: privateKeyToAccount(AGENT_KEY) }) // the agent's hot key
const grant = await owner.sessions.grant({
executor: executor.address,
maxTradeUsd: 20, maxDayUsd: 60, maxTrades: 50, hours: 24,
allow7702: true, // the owner's consent to set the wallet's code
})
await owner.sessions.setup(grant) // once, only if the session sells a token such as USDG
const t = await executor.sessions.trade({ sessionId: grant.sessionId, session: grant, sell: 'ETH', buy: 'USDG', amountUsd: 5 })
console.log(t)from eth_account import Account
from orblivion import OrblivionClient
owner = OrblivionClient(account=Account.from_key(OWNER_KEY)) # the main key
executor = OrblivionClient(account=Account.from_key(AGENT_KEY)) # the agent's hot key
grant = owner.sessions.grant(executor=executor.address, max_trade_usd=20, max_day_usd=60, max_trades=50, hours=24,
allow_7702=True) # the owner's consent to set the wallet's code
owner.sessions.setup(grant) # once, only if the session sells a token such as USDG
t = executor.sessions.trade(grant.session_id, sell="ETH", buy="USDG", amount_usd=5, session=grant)
print(t)Over the API, a grant takes two calls to POST /api/v1/live/session/grant.
1. Start it, with the main key's API key:
| Field | Default | Meaning |
|---|---|---|
wallet | required | the owner's wallet (the key's) |
executor | required | the executor's address; not the wallet, not the co-signer |
tokens | ["ETH", "USDG"] | the tokens the session may trade: core symbols or addresses, at least two |
max_trade_usd | 25 | the most one trade may spend; at most $1,000 |
max_day_usd | 100 | the most per 24 hours; from max_trade_usd to 100 times it |
max_trades | 50 | from 1 to 10,000 |
expires_in_s | 86400 | from 60 seconds to 30 days; at most 24 hours when a token has no Chainlink feed (ORBIO, and any token by address whose price reference isn't one) |
setup_budget_usd | max_day_usd | the ERC-20 budget for the whole session |
accept_risks, shield, policies | none, false, off | the session's risk terms |
The answer is a pending session and everything the main key signs: sign.delegation (the EIP-712 typed_data, its digest, the delegation_hash and the binding), sign.authorization for a plain wallet, sign.curve_roots for curve tokens, the setup steps, caveats with what each means, the limits, risk_terms, on_chain_bounds, exposure (approvals the wallet has outside the session, which it should revoke) and not_enforced_on_chain.
2. Switch it on: the same route with session_id, delegation_signature, and authorization_signature (plain wallets) and curve_root_signatures ({delegation_hash: signature}, curve tokens). Orblivion recovers each signature, the co-signer registers the root after checking it against the binding itself, and the session is active.
Trade in a session#
POST /api/v1/live/session/trade with the executor's (or the wallet's) API key:
{"session_id": "ses_...", "token_in": "ETH", "token_out": "USDG", "notional_usd": 5}It also takes amount_in, slippage_bps, min_amount_out, route_id and fund. It doesn't take a fee or risk terms: those are the binding's, and a request that names other accept_risks, shield or policies is refused (SESSION_TERMS).
Orblivion checks the session (each a checks entry, and a refusal with that code when it fails):
| Check | Refuses when |
|---|---|
SESSION_PENDING, SESSION_REVOKED, SESSION_EXPIRED, SESSION_EXPIRY | the session isn't switched on yet, was revoked, or has ended or is about to (409) |
SESSION_BOUND | the stored binding doesn't hash to the root's salt (409) |
SESSION_TOKENS | a token outside the session |
SESSION_TRADES_LEFT | the session's trades are used up |
SESSION_MAX_TRADE, SESSION_MAX_DAY | over the per-trade cap, or the 24-hour cap counting every co-signed trade, sent or not |
SESSION_TARGET, SESSION_RECIPIENTS | the swap isn't a router execute, or pays anyone but the wallet, the router or the fee recipient |
SESSION_SETUP | a token input without the setup in place |
Then the co-signer checks the trade again on its own and co-signs the leaf. The answer carries the executor's transaction (tx: redeemDelegations to the DelegationManager, type 4 on the first trade of a plain wallet), the leaf with its caveats, the decoded execution and swap, its simulation, spend (last_24h_usd, remaining_24h_usd, trades_used, trades_left) and the disclosures. ready is true when it can be sent as it is. The executor signs it and sends it through POST /api/v1/live/submit.
A token still on its Pons curve trades in a session too, through more roots the owner signs at the grant: a curve root, and one fee root for each token its trades pay the fee in. Each trade is one redeemDelegations of two redemptions, which land together or not at all. The fee is paid in the pair (ORBIO or ETH, which outrank the token; see which token pays the fee), so an ORBIO-paired token needs one fee root, in ORBIO: a buy redeems the fee and then the curve call, and a sale the curve call and then the fee, exactly floor(min_out × bps / 10,000) of the sale's guaranteed minimum. The SDKs and the co-signer refuse any other set of roots (CURVE_ROOT_SET).
What the co-signer enforces#
The co-signer is a separate service from the API, with its own key, its own price readings and its own records. It takes structured requests only, never a raw digest, and before each leaf it checks the trade with the Python SDK's own decoder and rules, not the API's code:
- the call is the pinned Universal Router's
execute, and every recipient is the wallet or the router, except exactly one fee payment to the fee recipient, of a fee the binding allows for the leaf's class, which the co-signer reads itself from the leaf's two tokens; - that fee is in the token and on the side the fee currency rule gives, and in its place: from the input right after the router receives it and before any swap, or from the output after the last swap (
FEE_TOKEN,FEE_SIDE,FEE_ORDER); - nothing is left in the router, the tokens and hooks are allowed, and the minimum output is above zero;
- the minimum output is at most
max(slippage, 300 bps)under the input's value at its own Chainlink reading, or, for a token without a feed, at most 1,000 bps (plus the curve's fee and creator tax) under the price the owner signed. With no fresh reading it refuses (PRICE_UNAVAILABLE); - the per-trade, per-24-hour and trade-count caps, which it counts itself by root;
- the risks reported for the trade are all accepted by the binding, and no Shield policy in the binding refuses it.
Anything else is refused and logged. Its operator can freeze it outright, and then it signs nothing.
Risk terms#
An agent can't accept risks on its owner's behalf. In a session, accept_risks, shield and policies are set at the grant and bound into the root's salt:
{"wallet": "0x...", "executor": "0x...", "max_trade_usd": 20, "max_trades": 10,
"accept_risks": ["HIGH_PRICE_IMPACT"], "shield": true, "policies": {"verified_only": true}}- Every trade takes its terms from the binding, never from the request. Neither a prompt-injected agent nor a stolen executor key can widen them.
- The fees follow the binding and its Shield mode (below).
- Sessions granted before binding version 3 keep working with the strictest reading of what was signed: no risk accepted, no Shield, and schedule 1's 10 bps.
Fees#
A version 4 binding names the fees the session may pay, resolved for its Shield mode:
"fees": {"major_max_bps": 25, "major_min_bps": 10, "other_bps": 50}With Shield they are {"major_max_bps": 35, "major_min_bps": 20, "other_bps": 60}. Any other value is refused.
- A trade is major when both of its tokens are majors, and other otherwise (see Fees and gas). The co-signer reads each leaf's class itself, from the leaf's two tokens and its own on-chain stock check, and ignores the server's.
- A major leaf pays one of the pinned tiers between
major_min_bpsandmajor_max_bps: the wallet's volume tier, from 0.25% down to 0.10% (0.35% down to 0.20% with Shield). - An other leaf pays exactly
other_bps. - A curve token is always other, so a curve session's fee root caps its fee transfers at
other_bps. - The SDKs, the relay and the co-signer each check the fee against the binding.
Sessions granted before schedule 2 (binding versions 1 to 3) keep schedule 1's one fee on every leaf until they expire: 10 bps for versions 1 and 2, and for version 3 its fee_bps, 10 or 15 with Shield.
Limits#
| Limit | Value | Enforced by |
|---|---|---|
| ETH per trade | the per-trade cap at the grant's ETH price | the chain (ValueLte) |
| ETH per 24-hour window | the day cap, in fixed windows from the grant | the chain (NativeTokenPeriodTransfer) |
| ERC-20s | the setup budget, for the whole session | the chain (Permit2) |
| Number of trades, expiry, target, method, redeemer | as signed | the chain |
| Dollars per trade and per 24 hours | as signed | Orblivion and the co-signer, each counting |
| Recipient and minimum output | the co-signed leaf's exact call | the chain, through the leaf |
| Price | at most max(slippage, 300 bps) under its own reference | the co-signer |
| A leaf | one exact call, at most two minutes, once | the chain |
| Session length | 30 days; 24 hours with a token without a Chainlink feed | Orblivion and the co-signer |
The grant's not_enforced_on_chain lists what the chain alone doesn't bind: the output's recipient and the minimum output (the co-signed leaf binds them), the dollar cap per day for anything but ETH, the ERC-20 amount per trade, and which tokens are bought.
Revoking#
POST /api/v1/live/session/revoke with session_id (the wallet's key):
- At once, at Orblivion: the co-signer co-signs nothing more for the session. A leaf already issued still works, once, until its two minutes run out.
- On chain, the answer's
on_chainsteps for the main key to send:disableDelegation(root), which makes the DelegationManager refuse every later redemption, and the setup's approvals (and any curve allowances) back to 0. Withreset_code: trueit also describes the authorization that resets the wallet's code.
Send the on-chain steps whenever you suspect a key. They work whatever happens at Orblivion, even if it is down.
await owner.sessions.revoke({ sessionId: grant.sessionId }) // checked, signed and sent with the main key
await owner.wallet.resetCode() // optional: back to a plain walletowner.sessions.revoke(grant.session_id) # checked, signed and sent with the main key
owner.wallet.reset_code() # optional: back to a plain walletIf a key is stolen#
| Stolen | What the thief can move |
|---|---|
| The executor key | nothing beyond trades the co-signer co-signs: each leaf is one exact call, once, for two minutes, paying the wallet. The executor's own ETH (its gas money) is exposed. |
| The co-signer's key | nothing: only the executor can redeem. |
| Both | per 24-hour window, at most min(ETH per trade × trades left, the day cap) of ETH, plus the ERC-20 budget for the whole session, to any recipient and at any price, until the expiry. The windows are fixed from the grant, so up to twice the day cap can move across a window boundary. A curve session adds trades on that token's own curve, always paying the wallet but at any price, up to its curve approval. |
| The main key | everything in the wallet. A session doesn't change that. |
Plainly: no deployed contract can check the recipient or the minimum output of a Universal Router swap. The co-signer's leaf binds them, so the executor key alone gets nothing. With both keys stolen, the root's caveats are the bound. So keep sessions short and caps small, keep the executor's gas money small, and revoke on any suspicion.
Costs#
A session trade costs more gas than a direct one: about 557k gas against 207k, roughly two cents at today's prices, because the DelegationManager checks every caveat. The first trade from a plain wallet adds a little for the authorization. The executor pays the gas.