> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 0012. A Session Key is not trade-only on-chain; its scope rests on Polymarket's relayer

# 0012. A Session Key is not trade-only on-chain; its scope rests on Polymarket's relayer

* **Status:** Proposed
* **Date:** 2026-09-30

## Context

The app signs with a Polymarket Session Key for the Deposit Wallet ([ADR 0009](0009-venue-credentials-in-memory.md)).
Gate item 5 (security finding P3-01, F4) needs evidence that a leaked key can only trade.
The docs say "A Session Key cannot withdraw funds from the Deposit Wallet"
([https://docs.polymarket.com/trading/session-keys.md](https://docs.polymarket.com/trading/session-keys.md)). The questions in
[secrets-and-logging.md §7](../security/secrets-and-logging.md#7-polymarket-session-key-creation-and-scope)
were answered from the verified wallet source, read-only `eth_call`s to a public Polygon RPC
(block 94703939), the docs and `@polymarket/client` 0.11.0. Nothing was signed or sent.

## Decision

* **Trade-only does not hold on-chain.** The wallet contract gives a Session Key almost the
  owner's power: it can sign wallet batches that call any contract except the wallet and its
  beacon, and `isValidSignature` accepts its signature over any digest. The only barrier to
  moving funds is that Polymarket's relayer is the sole submitter of batches. Whether the
  relayer refuses session-signed batches that transfer or approve tokens is not public:
  **UNVERIFIED**, and not determinable from public sources.
* Gate item 5 stays **failed** and the live run stays blocked until one of the options in
  [Gate 5 decision](#gate-5-decision) is recorded.

## Gate 5 decision

Security recheck condition C1
([findings-phase-3-recheck.md](../security/findings-phase-3-recheck.md#gate-items-5-and-6-after-adr-0012)).
The live-verification runbook checks this section before arming (pre-flight PF1). The
options, strongest first:

1. **Polymarket statement.** Polymarket states in writing that the relayer refuses
   session-signed batches other than session-key management. Item 5 passes.
2. **Owner relayer test.** From a permitted location, outside this repo and with the
   official SDK, the owner submits a session-signed batch that moves 1 base unit of pUSD to
   the owner, and the relayer refuses it. Item 5 passes for the relayer as it behaves on the
   test date. This app does not build that request (CLAUDE.md: no relayer features).
3. **Accepted risk.** The user and the tech lead accept in writing that a leaked key can lose
   everything in the Deposit Wallet, bounded by the cap in
   [ADR 0013](0013-working-balance-cap.md). Item 5 is then Accepted risk, which holds only
   while item 6 (the cap) holds. It covers the attended live run at the ADR 0013 stage 1
   cap, not raising the cap.

The live run (ADR 0013 stage 1) needs any one of the three. Normal operation with a working
balance of $100k to $500k (ADR 0013 stage 2) needs option 1 or 2. The tech lead recommends
option 2, and option 3 for the attended run only if the test cannot be done.

**Decision: pending the user; to be raised when all phases are complete.**

| Field | Entry |
| - | - |
| Option (1, 2 or 3) | |
| Date (UTC) | |
| Evidence: the statement, the test's request and the relayer's reply, or the written acceptance | |
| Accepted by (option 3: the user and the tech lead) | |

## Evidence

Beacon [`0x7A18…c3a`](https://polygonscan.com/address/0x7A18EDfe055488A3128f01F563e5B479D92ffc3a#code):
`implementation()` returns `0xf7f27c29e60fe6325bef8da7f93250353d2e3294`, verified as
`src/DepositWallet.sol` ([Sourcify](https://sourcify.dev/server/v2/contract/137/0xf7f27c29e60fe6325bef8da7f93250353d2e3294?fields=all)).
Wallets from before 2026-06-29 run [`0x58CA…B1eB`](https://sourcify.dev/server/v2/contract/137/0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB?fields=all), the same design.
Factory implementation: [`0x528c…abd7`](https://sourcify.dev/server/v2/contract/137/0x528cc05efac2b0d255e423272187efd41248abd7?fields=all).
Audits: [Polymarket/contract-security](https://github.com/Polymarket/contract-security/tree/main/audit-reports/deposit-wallet).

| §7 question | Answer | Source |
| - | - | - |
| 3. Any digest? | Yes. `isValidSignature` (L640–685) takes the signer from the `0x6492…` envelope, checks only `block.timestamp < sessionSignerAuthorizedUntil(signer)`, then calls Solady `ERC1271`, which accepts an ERC-7739 `TypedDataSign` for any app domain or a `PersonalSign` of any hash. No check on verifying contract, type or scope | `DepositWallet.sol`, `lib/solady/src/accounts/ERC1271.sol` L24–46, L98–285 |
| 3. Can the key move funds? | Yes, through `execute` (L271–322): a session-signed batch may call any target except the wallet and the beacon, so `pUSD.transfer`/`approve` and CTF `safeTransferFrom`/`setApprovalForAll` pass. `execute` is `onlyFactory`; the factory's `proxy` is `onlyOperator` (the relayer) | `DepositWallet.sol`; `DepositWalletFactory.sol` L206–210, L317 |
| 3. Token permits | pUSD `permit` uses `ecrecover` only, so it cannot sign for a contract wallet; no `transferWithAuthorization`. CTF has no signature approval. Not exploitable through the any-digest rule | pUSD impl `0xce84…25de` (Solady ERC20); CTF source |
| 3. What auditors say | Zellic 3.1: a stale session signer "can perform token transfers and approvals"; Polymarket: handled "offchain … through our relayer". Certora: session signer can "sign batches only (cannot call wallet itself)", relayer "semi-trusted" | Zellic (Mar 2026), Certora (Mar 2026) |
| 4. Limits | Scopes (`CLOB`, `COMBOSRFQ`, `ALL`) exist only off-chain: the contract stores `validUntil` per signer and nothing else. Expiry is fixed at 180 days. No notional cap, no exchange allowlist | `DepositWallet.sol` L176–187, L332–338; session-keys.md |
| 5. Revocation | `revokeSessionSigner` is `onlySelf`: an owner-signed batch through the relayer. It takes effect when mined. The docs: revocation "cancels its open orders" and "may take several minutes". Owner-only fallback: `pause()`, wait `timelockDelay` (3600 s on-chain), `revokeSessionSignerEmergency`; pausing alone does not stop the key. Whether L2 credentials stop at once: **UNVERIFIED** | `DepositWallet.sol` L343–347, L407, L492; session-keys.md |
| 6. Check at order time | Placement: off-chain only; the docs say keys are usable once listed by `/v1/user/session-signers`. How the CLOB checks scope: **UNVERIFIED**. Settlement: the exchange calls `isValidSignatureNow(maker, …)` on the wallet, so an expired or revoked key fails at match, except a `preapproved` order hash | CTF Exchange V2 `Signatures.sol` L47–56, L156–166 |
| `valid_until` | Unix seconds; valid while `block.timestamp < validUntil`; `authorizeSessionSigner` requires it in the future | `DepositWallet.sol` L332–338, L661 |
| 1–2. Creation | Generated on the trading host; only the address is authorised by an owner-signed batch sent to the relayer with a Builder API key | session-keys.md, wallets-auth.md |

## Consequences

* A leaked Session Key can lose the whole Deposit Wallet balance, pUSD and outcome tokens,
  if the relayer accepts a session-signed transfer batch. Even if it does not, the key can
  sign orders at any price and trade the balance away to an attacker's orders (threat T36).
  Either way the working-balance cap (ADR 0013) is the bound.
* Our signer allowlist (`signing/typeddata.go`) and `lint:scope` stop this app from signing
  a batch or permit. They do not help once the key has leaked.
* Response to a suspected leak: revoke through polymarket.com or the SDK as the owner; if the
  relayer is unavailable, pause and revoke through the emergency path after an hour.
* The session-key runbook, the threat model and secrets-and-logging §7 should cite this
  record in place of the docs sentence.
* Detection is not guaranteed (P3F-04). F5 flags outgoing transfers that the Data API reports
  as activity, but whether a raw session-signed `pUSD.transfer` batch appears in `/v2/activity`
  at all is UNVERIFIED. An unexplained drop in cash is checked from Phase 4.

## Alternatives considered

* **Treat the docs sentence as sufficient.** Rejected: the contract source contradicts it
  as a contract property.
* **Encrypted keystore holding the owner key (PLAN §7 fallback).** Rejected: the owner key has
  strictly more power, and the owner key must never be on the app host.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.