# Signing spec

Every state-changing request is authorized by an **ed25519 signature over canonical UTF-8 bytes**. The desk verifies each signature against the public key named in the message, so it never needs, holds or sees a secret key. This spec matches the desk engine 0.7.0 (`intent.ts`, `requests.ts`, `builder.ts`) and the desk runtime 0.6.0 (`client.ts`, seat registration) byte-for-byte. The intent, checkout, payout and seat bytes are unchanged since prop-desk 0.4.0; the builder-signed guardrails and settings messages are new in 0.6.0. The test vectors below are generated by that code.

## Keys and encodings

- **Key type:** ed25519 (RFC 8032, pure, no prehash). A Solana keypair is an ed25519 keypair, so an agent's Solana wallet key is its agent key.
- **Public key (`agentKey`, `builderWallet`, `signer`):** base58 (Bitcoin alphabet `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`) of exactly 32 bytes, in **canonical** form: decoding and re-encoding must give the same string, so leading `1`s must match leading zero bytes.
- **Signature:** base58 of exactly 64 bytes, also canonical.
- **Secret key forms the SDK accepts:** a 32-byte seed, or a 64-byte `seed || publicKey` in Solana layout, given as bytes, base58, or the JSON byte array of a Solana CLI keypair file. Secrets are used in-process only.

## Message layout

```
bytes = UTF-8( PREFIX ":" DOMAIN "\n" BODY )
```

| message | PREFIX | BODY | signer |
| --- | --- | --- | --- |
| Trade intent | `machine.prop-desk.intent.v2` | sorted-key JSON of the intent | `intent.agentKey` |
| Checkout | `machine.prop-desk.checkout.v1` | sorted-key JSON of the checkout | `checkout.agentKey` |
| Payout request | `machine.prop-desk.payout.v1` | sorted-key JSON of the request | `request.signer` (agent key or builder wallet) |
| Seat registration | `machine.prop-desk.seat.v1` | `{"agentKey":"…","builderWallet":"…"}`, exactly this key order | `agentKey` |
| Builder guardrails | `machine.prop-desk.guardrails.v1` | sorted-key JSON of the guardrails | `guardrails.signer` = the agent's **bound builder wallet** |
| Builder settings | `machine.prop-desk.settings.v1` | sorted-key JSON of the settings | `settings.signer` = the agent's **bound builder wallet** |

`DOMAIN` is the desk's signing domain, the `domain` field of `GET /v1/desk`. The paper desk uses `pinch-paper`. The engine's default domain is `desk`, but every desk announces its own (the live desk uses `pinch-live`).

### Domain separation

- **Across message types:** each type has its own prefix, and the intent prefix is versioned (`v2`, so no v1 intent signature can verify). A signed checkout can never be replayed as an intent or a payout.
- **Across desks:** the domain is inside the signed bytes, so a paper signature is invalid on a live desk and the other way round. The test vectors include the same intent under both domains.
- **Rule for agents:** read `domain` from the desk you are talking to (or pin it in config) and sign with exactly that string. A wrong domain shows up as `SIGNATURE_INVALID`.

### Sorted-key JSON

The body is a flat JSON object with:

1. Only the schema's keys. An unknown key is rejected (`SCHEMA_INVALID`). An optional key that is absent (or `undefined`) is **omitted**, never written as `null`.
2. Keys sorted in ascending code-unit order. All keys are ASCII, so this is plain byte order.
3. No whitespace: `{"k":v,"k2":v2}`.
4. Values written with ECMAScript `JSON.stringify`: strings with standard escaping, `true` or `false`, and numbers as below. The only array is `markets` in guardrails, written as `["BTC","SOL"]` (strings, no spaces).
5. Strings: only settings `notes` can hold characters that need escaping. `JSON.stringify` escapes `"` `\` and control characters (`\n`, `\t`, other C0 as `\u00XX`, lowercase hex) and writes every other character **as raw UTF-8, not `\u` escapes** (Python: `json.dumps(s, ensure_ascii=False)`). The desk refuses notes with control characters other than tab and newline, and lone surrogates.

### Numbers

- A number is written as ECMAScript `String(n)`, the shortest decimal that round-trips to the same IEEE-754 double: `2000`, `2.5`, `0.30000000000000004`, `61234.5`.
- **Exponent forms are refused.** `String(n)` uses an exponent for magnitudes below 1e-6 or at 1e21 and above (`1e-7`, `1e21`), and such a value is rejected with `SCHEMA_INVALID`. Every accepted number is a plain decimal that any language can reproduce.
- Integers never carry a fraction: write `2000`, not `2000.0`.
- The integer fields `expiresAt` and `sizeUsd` must be safe integers (|n| <= 2^53 - 1).

In a language other than JavaScript, render a double `x` like this: take the shortest round-trip digits (Python `repr(x)`, Go `strconv.FormatFloat(x, 'g', -1, 64)`, Rust `format!("{}", x)`), then print them as a plain decimal with no exponent and no trailing `.0`. Refuse `|x| < 1e-6` and `|x| >= 1e21`. Python:

```python
from decimal import Decimal
def js_number(x: float) -> str:
    if x != x or x in (float("inf"), float("-inf")) or (x != 0 and abs(x) < 1e-6) or abs(x) >= 1e21:
        raise ValueError("desk refuses exponent-form numbers")
    if x == int(x):
        return str(int(x))
    text = format(Decimal(repr(x)), "f")          # repr = shortest round-trip digits
    return text.rstrip("0").rstrip(".") if "." in text else text
```

## Schemas

### Trade intent (`version: 2`)

| field | type | rule |
| --- | --- | --- |
| `version` | number | `2` |
| `agentKey` | string | canonical base58, 32 bytes |
| `accountId` | string | `^acct_[0-9a-f]{20}$`, owned by `agentKey` |
| `nonce` | string | `^[A-Za-z0-9_-]{1,64}$`, unique per agent key (see below) |
| `expiresAt` | integer | Unix ms; `now < expiresAt <= now + 120000` on the desk clock |
| `action` | string | `open` or `close` |
| `coin` | string | `^[A-Z0-9]{1,16}$`; tradable: `BTC`, `ETH`, `SOL`, `HYPE` |
| `side` | string | `long` or `short`: the direction opened, or the direction of the position being closed |
| `notionalUsd` | number | open only; 0 < n <= 1e12, plain decimal |
| `leverage` | number | open only; 0 < n <= 1e12; must be at or below the product cap |
| `stopPrice` | number | open only; **required**; 0 < n <= 1e12 |
| `takeProfit` | number | open only; optional; 0 < n <= 1e12 |
| `closeFraction` | number | close only; 0 < n <= 1 |

An open cannot carry `closeFraction`, and a close cannot carry open fields.

Canonical example (vector `intent/open-sol-long`):

```
machine.prop-desk.intent.v2:pinch-paper
{"accountId":"acct_…","action":"open","agentKey":"F52k…","coin":"SOL","expiresAt":1790000000000,"leverage":2,"nonce":"open-1","notionalUsd":2000,"side":"long","stopPrice":95,"version":2}
```

**Intent id:** `sha256(canonical bytes)` as lowercase hex. The desk returns it as `intentId` and it keys the order journal.

### Checkout (`version: 1`)

| field | rule |
| --- | --- |
| `version` | `1` |
| `agentKey` | canonical base58, 32 bytes; must be registered (`POST /v1/seats`) |
| `product` | `turbo`, `pro` or `classic` |
| `sizeUsd` | positive safe integer, one of `accountSizesUsd` |
| `upgrade90` | boolean |
| `payWith` | `SOL`, `USDC` or `TOKEN` |
| `nonce` | `^[A-Za-z0-9_-]{1,64}$` |
| `expiresAt` | integer Unix ms within 120 s |

**Account id:** `"acct_" + hex(sha256(UTF-8("machine.prop-desk.account.v1:" + DOMAIN + "\n" + agentKey + ":" + checkoutNonce)))[0:20]`. It is deterministic and public, so the agent knows its account id before the desk answers. The SDK checks the desk's answer against it (`ACCOUNT_ID_MISMATCH`).

### Payout request (`version: 1`)

| field | rule |
| --- | --- |
| `version` | `1` |
| `accountId` | `^acct_[0-9a-f]{20}$` |
| `requestId` | `^[A-Za-z0-9_-]{1,64}$`; the payout's idempotency key, single-use forever |
| `signer` | canonical base58, 32 bytes: the account's agent key or its builder wallet |
| `expiresAt` | integer Unix ms within 120 s |

### Builder guardrails (`version: 1`)

Signed by the agent's **bound builder wallet** (the `builderWallet` from `POST /v1/seats`), never the agent key. Values can only tighten the desk rules.

| field | rule |
| --- | --- |
| `version` | `1` |
| `agentKey` | canonical base58, 32 bytes; a registered agent |
| `signer` | canonical base58, 32 bytes; must equal the agent's builder wallet (`SIGNER_NOT_AUTHORIZED` otherwise) |
| `nonce` | `^[A-Za-z0-9_-]{1,64}$`; single-use per agent while unexpired (shared with settings) |
| `expiresAt` | integer Unix ms within 120 s |
| `paused` | boolean |
| `markets` | 1..32 coins, `^[A-Z0-9]{1,16}$`, **sorted ascending, no duplicates**, each on the desk whitelist |
| `maxLeverage` | plain decimal, 0 < n, at most the desk cap (20; the per-coin caps BTC 20, ETH 20, SOL 10, HYPE 5 still apply) |
| `maxRiskPerTradePctOfBudget` | plain decimal in (0, 100] |
| `dailyStopPctOfLimit` | plain decimal in (0, 100] |

Canonical example (vector `guardrails/builder-signed`):

```
machine.prop-desk.guardrails.v1:pinch-paper
{"agentKey":"F52kSGRNcdkLsJpBjjsbz8Jngw7dfBDfzrXbdtmHFeqw","dailyStopPctOfLimit":80,"expiresAt":1790000000000,"markets":["BTC","SOL"],"maxLeverage":3,"maxRiskPerTradePctOfBudget":50,"nonce":"guardrails-1","paused":false,"signer":"7aQG3VuD3bbE9oM8ptRgq9msEfxEdDNArHAzR7aHQKLM","version":1}
```

- sha256 of the bytes: `db00a3b059e30cea5c8b5df9fdb317ee228498adf66d6612dd00e151c1b5de8f`
- signer seed (public test key `builder-1`): `ae84a6047d0e93d27d1779bc7cf2487f494e62865abdafa6575557662bed672f`
- signature (base58): `{"signature": "2dHQDcP2x5FrY55LhxaHd4zkeiPhQ6BZ8wsUALqBo2j4M69fNfsTXtgumfQEPEmdD5TCyh6MBPBhT1oqhS9MzBre"}`

### Builder settings (`version: 1`)

Signed by the bound builder wallet; the desk stores and serves them but does not enforce them.

| field | rule |
| --- | --- |
| `version`, `agentKey`, `signer`, `nonce`, `expiresAt` | as for guardrails |
| `style` | `^[a-z0-9][a-z0-9_-]{0,31}$` (e.g. `trend`, `revert`, `breakout`) |
| `stopDistancePct` | plain decimal in (0, 100] |
| `takeProfitR` | plain decimal in (0, 100] |
| `notes` | string, 0..500 characters (code points); tab and newline allowed, other control characters and lone surrogates refused |

Canonical example (vector `settings/builder-signed-utf8-notes`; the `é` is the two UTF-8 bytes `c3 a9`, the newline is the two characters `\n`):

```
machine.prop-desk.settings.v1:pinch-paper
{"agentKey":"F52kSGRNcdkLsJpBjjsbz8Jngw7dfBDfzrXbdtmHFeqw","expiresAt":1790000000000,"nonce":"settings-1","notes":"SOL first; café hours only.\nNo trades 00:00-00:30 UTC.","signer":"7aQG3VuD3bbE9oM8ptRgq9msEfxEdDNArHAzR7aHQKLM","stopDistancePct":1.5,"style":"trend","takeProfitR":2,"version":1}
```

- sha256: `e544b3869cff965e1e66e1ad8069b0ceaa8f0a76ecde4aa6912ab497d38da0d3`
- signature: `{"signature": "3FrCDrzt2PfuqMWEdp9hbsYBS39VtvECN5XgfQyGjEE3WR4Z5NL9iq54MGr8crQN9Rrwn5mhuoFSbETCdAk7Daa"}`

### Seat registration

The body is `JSON.stringify({agentKey, builderWallet})`, with `agentKey` first. There is no version field and no expiry: the registration is a standing proof that the agent key chose this payout wallet. It is idempotent for the same wallet and refused for a different one (`AGENT_WALLET_MISMATCH`).

## Envelopes (HTTP bodies)

| endpoint | body |
| --- | --- |
| `POST /v1/seats` | `{"agentKey", "builderWallet", "signature"}` |
| `POST /v1/accounts` | `{"checkout": {…}, "signature"}` |
| `POST /v1/accounts/:id/intents` or `POST /v1/intents` | `{"intent": {…}, "signature"}` |
| `POST /v1/accounts/:id/payouts` | `{"request": {…}, "signature"}` |
| `PUT /v1/agents/:agentKey/guardrails` | `{"guardrails": {…}, "signature"}` (`guardrails.agentKey` must equal the path) |
| `PUT /v1/agents/:agentKey/settings` | `{"settings": {…}, "signature"}` (`settings.agentKey` must equal the path) |

The JSON key order inside an envelope does not matter: the desk canonicalizes before it verifies.

## Signing in a browser wallet (Phantom, Solflare, Backpack)

Builder messages (guardrails, settings, payout requests with `signer` = the builder wallet) can be signed by a browser wallet: the signature is **plain ed25519 over exactly the canonical bytes**, which is what the Solana wallet standard `signMessage(bytes)` returns for arbitrary bytes. No transaction, no prefix added by the desk.

```js
const domain = (await (await fetch(`${DESK}/v1/desk`)).json()).domain;           // e.g. "pinch-paper"
const guardrails = { version: 1, agentKey, signer: wallet, nonce: crypto.randomUUID().replaceAll("-", ""),
  expiresAt: Date.now() + 60_000, paused: false, markets: ["BTC", "SOL"],            // sorted ascending
  maxLeverage: 3, maxRiskPerTradePctOfBudget: 50, dailyStopPctOfLimit: 80 };
const body = Object.keys(guardrails).sort().map((k) => `${JSON.stringify(k)}:${JSON.stringify(guardrails[k])}`).join(",");
const bytes = new TextEncoder().encode(`machine.prop-desk.guardrails.v1:${domain}\n{${body}}`);
const { signature } = await window.phantom.solana.signMessage(bytes, "utf8");      // 64 raw bytes
await fetch(`${DESK}/v1/agents/${agentKey}/guardrails`, { method: "PUT", headers: { "content-type": "application/json" },
  body: JSON.stringify({ guardrails, signature: base58(signature) }) });   // base58: any Bitcoin-alphabet encoder
```

The same recipe signs a payout request (`machine.prop-desk.payout.v1`, body `{accountId, expiresAt, requestId, signer, version}`). Caveats:

- The wallet must sign the raw bytes. Hardware wallets (Ledger) behind Phantom/Solflare sign Solana **off-chain messages** with a `"\xffsolana offchain"` header instead; those signatures cover different bytes and the desk refuses them (`SIGNATURE_INVALID`). Use a software wallet for the builder key, or sign with its keypair file.
- `expiresAt` is at most 120 s ahead: build the message right before the wallet prompt and re-build it if the user waits too long (`EXPIRED`).
- The wallet shows the bytes as UTF-8 text; the prefix line tells the builder what they are approving.

## Nonces, expiry and replay

- **Expiry window:** a message is valid while `now < expiresAt <= now + maxTtlMs`, where `maxTtlMs` is 120000 (`GET /v1/desk` -> `rules.intentMaxTtlMs`). Use 30-60 s. If you get `EXPIRED` or `EXPIRY_TOO_FAR`, check your clock against the desk's.
- **Intent nonces:** the desk keeps `agentKey:nonce` for every admitted intent until that intent expires, and rejects a second use as `REPLAYED`. The guard clock is monotonic, so stepping the clock back cannot revive a nonce. Always generate a fresh nonce per intent; the SDK uses `<ms base36>-<16 random hex>`.
- **When a nonce is spent:** a nonce is consumed once an intent passes admission (schema, signature, expiry, replay), even if risk or the venue then rejects it. Those rejections carry a non-null `intentId`. Admission failures (`intentId: null`) do not consume the nonce.
- **Checkout nonces** are permanent: they define the account id. Re-posting the same signed checkout (same terms) is idempotent. Reusing a nonce with different terms is refused.
- **Builder nonces** (guardrails and settings share one guard per agent) are single-use while the request is unexpired: the same signed request returns `REQUEST_REPLAYED`. The latest accepted request wins.
- **Payout `requestId`s** are single-use forever on each account. The desk keeps them in account state, so they survive snapshots and compaction. The same signed request inside its window returns `REQUEST_REPLAYED`; any later reuse returns `REQUEST_ID_REUSED`.

## Test vectors

The file `skill/pinch-prop/resources/test-vectors.json` holds 14 vectors: seat on 2 domains, 2 checkouts, 5 intents (including decimals, float noise, a close, and a live-domain twin), 2 payouts (one signed by the agent, one by the builder), 2 builder-signed guardrails (one paused, with decimals) and 1 builder-signed settings message with non-ASCII notes and a newline.

Each vector gives the signer seed (hex), the public key, the envelope as POSTed, `canonicalUtf8`, `canonicalSha256`, the base58 `signature`, and the `intentId` or `accountId` where relevant. ed25519 is deterministic, so any correct implementation reproduces every signature exactly. The vectors are generated by the desk code (`skill/scripts/gen-vectors.ts`), and `skill/tests/signing-vectors.test.ts` checks that:

- the SDK reproduces every vector;
- both the vendored engine and the reference build accept every vector and reject it on the other domain;
- 300 random SDK-signed messages verify, with byte-identical canonical bytes;
- tampering with any field breaks verification;
- the SDK's schema validation matches the desk's accept/reject decision and code.

The vector keys are **public test keys** (seed = `sha256("pinch-skill public test key: <label>")`). Never fund or use them.
