# POLYROB Desk — formal description and technical task

**Version:** 0.1 · **Date:** 2026-09-20 · **Status:** technical task for implementation; the design is
proposal 050 in the POLYROB tree (`docs/proposals/050-desk-nft-token-bound-trading-account.md`).
**Site:** `desk.polyrob.dev` (this page, `mint`, `desk?id=`).

> This document is the contract between the design and the code. Every "MUST" is a test. Every
> address is pinned in code and re-verified by code hash on use; a configuration value may only
> confirm a pinned address, never introduce one.

## 1. Definitions

| Term | Meaning |
|---|---|
| **Desk** | One token of the `PolyrobDesk` ERC-721 collection together with its ERC-6551 account and the ERC-8004 identity that account owns. |
| **Desk token** | The ERC-721. Carries `seed` (bytes32), `variant` (uint8) and the generator id. Held by the **owner**. |
| **Desk account** | The ERC-6551 token-bound account (Tokenbound AccountV3, salt 0) for the Desk token. Holds every asset. Its `owner()` is `ownerOf(tokenId)`, resolved live. |
| **Owner** | The address that holds the Desk token. Usually a person's own wallet. |
| **Operator** | An address with `permissions(owner, operator) == true` on the Desk account. The POLYROB instance's treasury key. |
| **Identity** | The ERC-8004 Identity Registry token registered by the Desk account, so `ownerOf(agentId) == deskAccount`. |
| **Journal** | The append-only, signed, digest-chained log of the Desk's decisions (§6). |
| **Adopt** | The procedure by which a POLYROB profile becomes the operator-side twin of a Desk (§7). |
| **Handover** | A change of `ownerOf(tokenId)` followed by a new operator grant and an adopt. |

## 2. Goals and non-goals

**Goals**

1. The portfolio, the fee streams, the reputation and the strategy history of an agent move with ONE
   transferable token.
2. The agent that trades is provably not the owner of the token and cannot outlive a sale.
3. A buyer's fresh POLYROB can verify, adopt and continue without trusting the seller.
4. No new authorizer, no custodian, no mint service, no central domain. Only canonical bytecode plus
   one small non-upgradeable ERC-721.

**Non-goals (v1)**

- A marketplace (OpenSea is the marketplace). Cross-chain Desks. Editions or evolving faces.
- Gating buyers by jurisdiction (we cannot; metadata carries the issuer's restriction text).
- Fully on-chain pixels (the seed is on-chain; the renderer is pinned by generator id).

## 3. Architecture

### 3.1 Objects

```
Desk token #id (ERC-721, PolyrobDesk)             owner = holder wallet
  └─ Desk account (ERC-6551 AccountV3, salt 0)     owner() = ownerOf(id)
       ├─ Identity (ERC-8004 agentId)              ownerOf(agentId) = Desk account
       ├─ assets: ERC-20 / ERC-721 / ERC-1155 / native
       └─ permissions[owner][operator] = true      the POLYROB treasury key
```

### 3.2 Custody models

| Model | NFT holder | Operator | Default |
|---|---|---|---|
| A — Held Desk | the owner's wallet | granted by `setPermissions` | **yes** |
| B — Self-held Desk | the instance treasury key | none needed (`owner() == key`) | for single-key self-hosters; `polyrob desk take` converts B → A via an owner-approved `nft_transfer` |

### 3.3 Pinned infrastructure (Robinhood Chain 4663, verified 2026-09-18 by code hash)

| Contract | Address | Note |
|---|---|---|
| ERC-6551 Registry | `0x000000006551c19487814612e58FE06813775758` | bytes equal on eth/base/rh |
| Tokenbound AccountV3 impl | `0x41C8f39463A868d3A88af00cd0fe7102F30E44eC` | `AccountV3Upgradable` |
| Tokenbound AccountProxy | `0x55266d75D1a14E4572138116aF39863Ed6596E7F` | |
| ERC-8004 IdentityRegistry | `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` | proxy; `name() = AgentIdentity` |
| ERC-8004 ReputationRegistry | `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` | |
| ERC-8004 testnet pair (46630) | `0x8004A818…BD9e` / `0x8004B663…8713` | first registration always here |
| `PolyrobDesk` collection | **to be deployed** (§5) | CREATE2, same address on every chain |

## 4. Core changes in the POLYROB tree

### 4.1 Phase 0 — `core/wallet/erc8004.py`

- Add `"robinhood": _mainnet("robinhood", 4663)` and `"robinhood-testnet": _testnet("robinhood-testnet", 46630)`.
- Replace the docstring paragraph that says ERC-8004 has no Robinhood deployment.
- AGENTS.md: replace "`robinhood` is deliberately ABSENT" with the verified fact and the date.
- Test: `test_robinhood_is_pinned_to_the_published_singleton`.

### 4.2 `core/wallet/erc6551.py` (new)

```
REGISTRY, ACCOUNT_V3_IMPL, ACCOUNT_PROXY          pinned addresses + sha256 code hashes
verify(rpc, chain) -> None | raises               re-reads eth_getCode, refuses on mismatch
account_address(chain_id, token_contract, token_id, salt=0) -> str
    keccak(0xff ‖ REGISTRY ‖ salt ‖ keccak(creation_code))[12:]
    creation_code = 0x3d60ad80600a3d3981f3363d3d373d3d3d363d73 ‖ impl
                  ‖ 0x5af43d82803e903d91602b57fd5bf3 ‖ salt ‖ chainId ‖ tokenContract ‖ tokenId
    ⚠️ the CREATE2 salt is the RAW salt (hashing the tuple yields a plausible wrong address)
encode_create_account(...), encode_execute(to, value, data, op=0), encode_set_permissions(callers, bools), encode_lock(until)
read_owner(rpc, account), read_state, read_locked_until, read_permission(owner, caller), read_implementation (ERC-1967 slot)
```

MUST: `account_address` reproduces the registry's `account()` for a fixture (`AccountV3, 0, 4663,
0x9eb6e2025b64f340691e424b7fe7022ffde12438, 232` → `0xe0f47c083b28c76124129786cbd02a4489b86ec4`).
MUST NOT: expose any signing helper. No `sign_typed_data`.

### 4.3 Guard: the key signs, the account holds

`TxIntent.via_account: Optional[str]` (the Desk account). When set:

1. The broadcast transaction is `{from: treasury, to: via_account, data: execute(inner_to, value, inner_data, 0)}`.
   The intent's `to`, `token`, `amount_raw`, inflow fields describe the INNER call.
2. `simulation.simulate(tx, holder=via_account, sender=treasury)` measures balances, allowances and
   events for `holder` exactly as today, and adds `sender_deltas`: the sender's native delta MUST be
   within `-(gas × price) ± dust` and its token deltas MUST be empty. Otherwise refuse:
   "the operator's wallet moved".
3. Pre-flight refusals before simulation: `isLocked()`, `permissions(owner(), treasury) == false`,
   implementation slot ≠ pinned, `state()` ≠ the value the verb read when it built the intent.
4. Rule 2 (turn origin), the caps, the lane and the 031 pause are unchanged. The USD priced is the inner
   call's. `PolicyGate.record(account=<desk>)` stamps the account so `wallet_spend`, the book and
   `trade_index` distinguish Desk movements from treasury movements.
5. `tx_guard.authorize` stays the ONE authorizer. `defi_trade_bridge` (verb-owned gate) is out of scope
   for Desks in v1.

Every money verb gains `desk: bool = False`; when the active profile has a bound Desk and `desk=True`,
the verb routes through it. Unchanged when unset.

### 4.4 Profile binding

`identity/{instance}/user_{uid}/desk.json`:

```json
{"chain": "robinhood", "chain_id": 4663, "collection": "0x…", "token_id": 42,
 "account": "0x…", "agent_id": 12345, "model": "held", "adopted_at": "…",
 "state_at_adopt": "0x…", "journal_head": "sha256…"}
```

Written only by `adopt`. Read by every Desk verb, the status snapshot and the seats.

## 5. The `PolyrobDesk` contract

### 5.1 Requirements

- ERC-721 (OpenZeppelin 5.x), non-upgradeable, no proxy, no admin over tokens.
- Per token: `seed` (bytes32), `variant` (uint8). Global `generator` string (e.g. `mindprint@1`), set at
  deploy, immutable.
- `mint(bytes32 seed, uint8 variant) payable returns (uint256 tokenId)` — mints to `msg.sender`;
  `price` owner-tunable, default 0; `seedTaken[seed]` MUST revert on reuse (one face, one Desk).
- `tokenURI(id)` = `baseURI/{id}` with an owner-settable base URI and a per-token `freezeURI` the owner
  may call once; `ERC-2981` royalty optional (owner-settable, ≤ 5%).
- Emits `DeskMinted(tokenId, seed, variant, to)`.
- Owner: the operator's cold address. Ownable2Step.

### 5.2 Seed

The site derives `seed = keccak256(bytes(word))`; the word is a human handle, never stored on-chain. The
POLYROB side treats `seed` as the Mindprint seed string source (`render_seed = seed + variant`, exactly
`modules/pfp/config.py`). Rob #1 = Desk #1 uses the committed `avatar/config/rob.json` identity.

### 5.3 Deployment

Through the existing `defi_trade.deploy_contract` with `create2_salt`, so the collection address is
identical on every chain. Runtime bytecode asserted byte-for-byte against the pinned template
(`core/wallet/desk_template.py`, mirroring `token_template.py`). Testnet 46630 first, then 4663.

## 6. The Desk Journal

### 6.1 Entry

```json
{"seq": 17, "ts": "2026-09-18T10:41:00Z", "desk": "0x…", "chain_id": 4663,
 "kind": "thesis|entry|exit|tend|note|handover",
 "text": "…", "refs": ["0xtxhash…"], "positions_after": {…},
 "operator": "0x…", "prev": "sha256(entry 16 canonical bytes)",
 "sig": "EIP-191 by operator over sha256(canonical bytes without sig)"}
```

Canonical bytes = JSON with sorted keys, no whitespace, UTF-8.

### 6.2 Rules

- `entry`/`exit`/`tend` are written by the money-verb rail (`PolicyGate.record`'s neighbour) for every
  Desk transaction. `thesis`/`note` are written by the `desk_journal` action — owner-tier or
  autonomous-goal turns only; never a forged, leaf or correspondent-tainted turn.
- Anchor: every `DESK_JOURNAL_ANCHOR_EVERY` entries and on every `handover`,
  `IdentityRegistry.setMetadata(agentId, "polyrob.journal", sha256(head))` through the Desk account
  (a `tx_guard` call with declared no-movement, priced at fee).
- The operator's first entry MUST be `handover`, countersigned by the previous operator's head (or by
  the holder wallet for a fresh Desk). An unbroken chain is provenance; a broken one is reported.
- Journal prose is DATA (UP-06 untrusted wrap) when read by an agent. PnL is computed from the chain.

### 6.3 Serving

`GET {agentURI-base}/journal.jsonl` and the registration file's `services[{name:"journal"}]`. When the
instance is not public, the journal is pinned to IPFS and its CID written as `polyrob.journal.uri`.

## 7. Adopt (`polyrob desk adopt <chain>:<collection>/<id>`)

Steps, each refusing with a named reason and the remedy:

1. `ownerOf(id)`; `account_address(...)`; `read_implementation == ACCOUNT_V3_IMPL`.
2. `isLocked() == false`; `permissions(owner, treasury) == true` (remedy: print the grant calldata + EIP-681 QR).
3. `IdentityRegistry.balanceOf(account) == 1` (0 → remedy `desk bind`; >1 → refuse, the Desk is ambiguous).
4. `state()` equals the snapshot pinned in the listing when one is supplied (`--expect-state`).
5. Seed/variant read from the collection → `pfp.json` written `locked: true`, `source: desk:<id>`.
6. Journal fetched, signatures verified per entry against the operator each entry names, digest chain
   verified, head compared to the on-chain anchor. Report `verified | gap at seq N | no anchor`.
7. Positions rebuilt from the chain (`portfolio`, `lp_positions`, `launchpad_status` fee escrow,
   ERC-721/1155 holdings via the NFT reads). Seeded into `open_positions.db` labelled `inherited`; the
   book verdict for an inherited position with no journal entry is `NO_LEDGER`.
8. `desk.json` written. The instance writes the **Desk Brief** foundation message.

`polyrob profile create --desk …` = create + adopt.

## 8. Seats and surfaces

| Seat | Surface |
|---|---|
| CLI | `polyrob desk mint|take|bind|adopt|grant|snapshot|journal|status`; `polyrob profile create --desk` |
| Telegram / REPL | `/desk` (status + snapshot), `/desk journal <text>` |
| Console | a Desk card inside **Money** (no new destination — 043 nav ratchet) |
| Status snapshot | `desk` section: account, owner, operator permission, lock, state, agentId, journal head/anchor, inherited count; health item when the operator permission is false or the anchor mismatches |
| Agent actions | `desk_journal`, `desk_snapshot` (read), `desk_bind`; every money verb's `desk:` flag |
| This site | `index`, `mint` (front-end, reads chain only), `desk?id=` (reads chain + the Desk's journal endpoint) |

## 9. Front-end (this site) — technical task

Static, no build, tokens vendored from the console (`styles/variables.css`), same rules as `polyrob.dev`:
no new palette, no rounded corners, no inline `<script>` beyond the page bootstraps (until the CSP
rule from the console is adopted here too).

| Page | Reads | Writes | Status |
|---|---|---|---|
| `index.html` | — | — | done (this drop) |
| `mint.html` | collection `price()`, `seedTaken(seed)`, wallet chain id; renders the face client-side with the product engine | `mint(seed, variant)` from the connected wallet; prints the three post-mint calls | front-end done; wallet + chain reads land with the contract |
| `desk.html?id=` | `ownerOf`, account address, impl slot, `isLocked`, `state`, `permissions`, 8004 `balanceOf`/`tokenURI`, holdings (Alchemy or the instance's `/desk/{id}/snapshot.json`), journal endpoint | — | front-end done with sample data |
| `spec.html` | this document | — | done |

Wallet: EIP-1193 + EIP-6963 discovery, chain 4663 only; a wrong chain shows the add-chain params
(`rpc.mainnet.chain.robinhood.com`, explorer `robinhoodchain.blockscout.com`). No SDK, no bundler:
`viem` is acceptable as a single pinned ESM import if needed; otherwise raw JSON-RPC.

Acceptance: every read is labelled; an unreadable value renders "unreadable", never 0 or empty;
`desk.html` refuses to show a face for a token whose implementation slot is not the pin (it shows the
refusal instead).

## 10. Flags

| Flag | Default | Meaning |
|---|---|---|
| `DESK_ENABLED` | off | registers the `desk` tool and the `desk:` routing on money verbs |
| `DESK_COLLECTION_ROBINHOOD` | pinned | narrow-only confirmation of the collection address |
| `DESK_JOURNAL_ANCHOR_EVERY` | 10 | entries per on-chain anchor |
| `DESK_LOCK_DEFAULT_HOURS` | 24 | window `desk snapshot --lock` proposes |

Every row needs `docs/CONFIGURATION.md` + `python scripts/gen_flags_catalog.py` (+ the user-guide regen).

## 11. Tests (must exist before any mainnet broadcast)

- `erc6551`: address math fixture (§4.2), code-hash pins, `execute` encode/decode round-trip.
- Guard with `via_account`: sender-moved-assets refuses; locked refuses; permission-false refuses;
  state-moved refuses; the existing NFT / call / claim / deploy guard tests parametrized over
  `via_account` produce identical verdicts for the inner call.
- Money-verb registration: `desk_*` verbs in all five lists (`test_money_verb_registration.py`).
- Journal: sign/verify, chain, tamper detection, untrusted wrap on read.
- Adopt: 0/2 identities refuse; seed adoption writes a locked `pfp.json`; inherited → `NO_LEDGER`.
- Contract: `seedTaken` reverts; runtime bytes match the template; CREATE2 address equals the computed one.

## 12. Build order

0. Phase 0 (ERC-8004 rows). Register Rob #1 on 4663.
1. `erc6551.py` + pins + tests.
2. `via_account` in simulation + guard + tests. Nothing broadcasts.
3. `PolyrobDesk.sol` → testnet 46630 via `deploy_contract`; mint; create account; bind.
4. Journal + anchor; `adopt` end-to-end on testnet with two profiles (seller → buyer), one lock window,
   one state-pin refusal.
5. Mainnet: deploy; mint Desk #1 (Rob #1's seed) to the owner wallet; grant the prod treasury; bind;
   first journal entry; first Desk trade under `DEFI_AUTONOMOUS_MAX_USD`.
6. Seats, status, docs, flags; this site's wallet layer.

## 13. Risks and answers

| Risk | Answer |
|---|---|
| Seller drains before sale | lock window + `state()` pin + signed snapshot; `adopt` refuses on drift |
| Owner-upgradeable account | implementation pin; a foreign impl is refused before any read is trusted |
| Operator key leaks | trades within caps until revoked; cannot move the NFT, lock, or upgrade — less exposure than the treasury today |
| Journal forged | signatures + anchors; gaps reported; PnL never from prose |
| Stock-token eligibility | issuer restriction text in metadata; no buyer gating |
| Lock binds the buyer | short default window, stated on the listing |
| Two operators | `state()` pre-flight; entries name the operator |
| Second treasury the seats do not show | `desk` status section from day one |

## 14. Owner decisions (open)

D1 custody default (A) · D2 one open collection, Rob #1 = #1, price 0 · D3 metadata hosting
(instance when public, IPFS fallback) · D4 lock window 24 h · D5 anchor cadence 10 · D6 ship Phase 0 now ·
D7 Standard Reserve tending in v1 only after a contract-caller check · D8 the name "Desk".
