# ERC-1271 contract-holder pairing for IMD

## Plain-language summary

IMD can support a smart contract that owns an identity.md seat without changing
EOA pairing: when `message.wallet` has deployed code, validate the existing
`WorkerAuthorization` EIP-712 data through ERC-1271 instead of requiring an
ECDSA recovery. Keep the existing pairing-code, nonce, expiry, NFT-ownership,
and single-seat rules exactly as they are. A contract then decides what counts
as approval; IMDSTR's strategy approves the exact digest on-chain and submits
`signature: "0x"`.

**Recommendation:** ship ERC-1271 in `POST /pair/complete`. It is one small,
standard, holder-controlled extension and preserves the existing signed message.
Do not use a delegate registry as the primary design.

## Evidence and confidence

`VERIFIED` means directly observed in a public source on 2026-09-25 UTC.
`SUSPECTED` is a design inference: the control-plane source is not public, so
its internal implementation was not inspected. Assignment-supplied facts are
identified as such, rather than represented as independently observed facts.

* **[D1, VERIFIED]** The [IMD API documentation, Authentication](https://imd.fun/docs/#auth)
  says Wallet authentication is an EIP-712 signature from the wallet holding
  the seat. Its [Pairing and agents section](https://imd.fun/docs/#pairing)
  documents `/pair/start`, `/pair/complete`, `/pair/wallet/:address`,
  `/agents/register-intent`, `/agents/bind`, every complete-body field, and the
  five-minute / single-complete code rule (lines 788--847 in the rendered
  document on the snapshot date).
* **[D2, VERIFIED]** The public worker repository at commit
  [`cb75443ecdef1b13128cbf89891dfd62864cd4ca`](https://github.com/Identity-md/worker/tree/cb75443ecdef1b13128cbf89891dfd62864cd4ca)
  tells operators to pair with the wallet that owns an eligible NFT and says
  that one NFT authorizes one active device
  ([README lines 63--76](https://github.com/Identity-md/worker/blob/cb75443ecdef1b13128cbf89891dfd62864cd4ca/README.md#L63-L76)).
  Its distributed CLI's minified `dist/cli.js:3`, function `runPairing`, sends
  only `{deviceKey}` to `/pair/start`, opens `/pair?code=...`, polls
  `GET /pair/:code`, and stores returned `wallet` and `tokenId`.
* **[D3, VERIFIED]** The verified-source response for
  [`IMDSeatStrategy` implementation](https://eth.blockscout.com/api/v2/smart-contracts/0x16d3f65b708883df042d98e1c7a49b32a33e2a14)
  contains `authorizeWorker` (source lines 143--158),
  `workerAuthorizationDigest` (178--206), and `isValidSignature` (208--218).
  It computes the exact typed-data digest below, records approved digests, and
  returns `0x1626ba7e` only while the matching NFT remains owned by the strategy.
* **[D4, VERIFIED]** ERC-1271 specifies `isValidSignature(bytes32,bytes)` and
  success value `0x1626ba7e`; it is expressly the standard for contract
  signatures ([ERC-1271, sections Specification and Rationale](https://eips.ethereum.org/EIPS/eip-1271)).
* **[D5, VERIFIED]** The docs list `/pair/complete` as the only Wallet-auth
  route in the documented pairing flow. `POST /agents/bind` is public and
  chain-checked; device actions use Ed25519, not a wallet signature
  ([docs lines 791--800 and 926--950](https://imd.fun/docs/#pairing)).

## 1. EOA assumptions in the documented flow

| Place | What is verified | Contract holder blocked? | Required change / confidence |
|---|---|---|---|
| Pairing page, `GET /pair` | It is an HTML page; `/pair/complete` requires a wallet's EIP-712 `WorkerAuthorization`. [D1] | **Yes today (assignment-provided maintainer statement).** A browser wallet's normal `eth_signTypedData_v4` is an EOA signing UX; a contract has no private key. | Detect code at the selected `wallet`. For an EOA, leave the current signing UI untouched. For a contract, show the exact message, accept contract-specific signature bytes, and submit after its approval transaction. **SUSPECTED** page implementation details: the page bundle/backend was not source-available. |
| `POST /pair/complete` | Body has `message.wallet`, token, nonce, expiry, relay origin, and `signature`; route is Wallet-authenticated. [D1] | **Yes if the server recovers an EOA from the signature, as maintainers reported.** ECDSA recovery cannot make a contract address the signer. | Branch by code and use the code below. This is the only required server authorization change. |
| Ownership re-check | The route can return `503 ownership unreadable`; docs describe `/pair/wallet/:address` as seats a wallet holds; worker connection says enrollment, token ownership, and registration are checked. [D1, D2] | **No inherent ERC-1271 block.** A normal `ownerOf(tokenId) == wallet` works for a contract address. | Do not replace it with “signature valid.” Recheck at pairing, and continue rechecking before admitting/retaining a live worker. |
| Wallet EIP-712 routes | Public docs define Wallet auth generically, but identify no other Wallet-authenticated seat-management endpoint. The documented paid-request signatures authorize payment, not seat ownership. [D1] | **Unknown / not in scope.** A contract may also be unable to use the current Permit2 payment UX, but that is separate from running a seat. | Inventory routes in the private server before release. Apply the same verifier only where the authorization semantically means “holder of this seat”; do not silently change payment authorization. |
| Agent registration/binding | `GET /agents/register-intent` supplies calldata for the holder; `/agents/bind` is public and chain-checked. [D1] The strategy source calls IMD's adapter itself. [D3] | **Not blocked by a holder signature.** The holder must be able to execute the adapter call. | No control-plane signature change. |
| Earnings / payments to holder | Docs expose `GET /wallets/:address/earnings` as a public read; they document no seat-holder withdrawal or payout route. [D1, Fleet and seats, lines 205--223]. | **No documented block can be established.** | Treat any undisclosed payout/claim route as an unanswered question; if it verifies a holder signature, give it an explicit ERC-1271 review rather than inheriting this change. |

The current docs and worker wording are evidence of the public protocol, not
proof that an EOA-only `ecrecover` exists in a particular server module. That
specific implementation is **SUSPECTED** from the maintainer statement and is
the behavior this patch replaces.

## 2. `POST /pair/complete` change

### Required invariant order

Keep the current parsing and validation. Load the pairing record by `code` and
compare every server-issued value before signature verification:

1. code exists, is unconsumed, and has not expired; `message.deviceKey`,
   `nonce`, `expiresAt`, and `relayOrigin` equal the record; enforce the
   existing nonce single-use rule;
2. use configured `chainId` and `nftContract`, not client fields;
3. at one pinned recent block, require `ownerOf(message.tokenId) ===
   message.wallet`; fail `503 ownership_unreadable` on RPC failure;
4. verify the *same* message below for `message.wallet`; and
5. in one conditional database update, consume the code and nonce and create
   the enrollment only if neither was consumed since step 1. Retain the
   existing one-active-device / enrolled-token conflict behavior (`409`).

Never consume a code on a bad signature or an RPC error. Return the existing
EOA invalid-signature response for an EOA. For a deployed contract that returns
non-magic / `false`, return:

```http
401
{"error":"invalid_contract_signature","detail":"wallet ERC-1271 validation returned non-magic value"}
```

For timeout, transport error, malformed RPC result, or revert, fail closed with
`503 signature_verification_unavailable`; do not convert an unavailable chain
into “signature invalid,” and do not enroll.

### Copyable TypeScript

This is deliberately the *same* EIP-712 payload for EOAs and contracts. Pin a
current viem v2 release that implements `publicClient.verifyTypedData`'s
ERC-1271/ERC-6492 universal verification path (verified against v2.56.9).
`gas` is passed through to its `eth_call`; the public viem parameter type does
not currently expose that optional call field, hence the narrow cast. Keep a
test that asserts the RPC request carries `0x3d090` (250,000). The five-second
transport timeout and no retries bound one request; the HTTP handler may retry
the whole operation only before consuming the code.

```ts
import { createPublicClient, http, type Address, type Hex } from 'viem'
import { mainnet } from 'viem/chains'

const CHAIN_ID = 1
const COLLECTION = '0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D' as Address
const ERC1271_CALL_GAS = 250_000n
const ERC1271_TIMEOUT_MS = 5_000

export const workerDomain = {
  name: 'IdentityMD Worker', version: '2', chainId: CHAIN_ID,
  verifyingContract: COLLECTION,
} as const

export const workerTypes = {
  WorkerAuthorization: [
    { name: 'deviceKey', type: 'bytes32' },
    { name: 'wallet', type: 'address' },
    { name: 'tokenId', type: 'uint256' },
    { name: 'nonce', type: 'bytes32' },
    { name: 'expiresAt', type: 'uint64' },
    { name: 'relayOrigin', type: 'string' },
  ],
} as const

type WorkerAuthorization = {
  deviceKey: Hex; wallet: Address; tokenId: bigint; nonce: Hex
  expiresAt: bigint; relayOrigin: string
}

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(process.env.ETHEREUM_RPC_URL!, {
    timeout: ERC1271_TIMEOUT_MS, retryCount: 0,
  }),
})

export async function verifyWorkerAuthorization(
  message: WorkerAuthorization, signature: Hex,
): Promise<{ valid: boolean; contract: boolean }> {
  // Call all chain reads at one block. The caller must map an RPC exception to
  // 503 and must not consume the pairing record.
  const blockNumber = await publicClient.getBlockNumber({ cacheTime: 0 })
  const code = await publicClient.getCode({ address: message.wallet, blockNumber })
  const contract = code !== undefined && code !== '0x'

  const request = {
    address: message.wallet,
    domain: workerDomain,
    types: workerTypes,
    primaryType: 'WorkerAuthorization' as const,
    message,
    signature,
    blockNumber,
    // Applies to the verifier's eth_call, including ERC-1271 validation.
    ...(contract ? { gas: ERC1271_CALL_GAS } : {}),
  }
  const valid = await publicClient.verifyTypedData(
    request as Parameters<typeof publicClient.verifyTypedData>[0],
  )
  return { valid, contract }
}
```

Immediately before the function, perform the pinned-block `ownerOf` check with
the collection ABI. Check expiry from the database and `message.expiresAt`
yourself; do not trust a contract to enforce the pairing expiry. `verifyTypedData`
also supports ERC-6492 wrappers, so a no-code/counterfactual wallet is not
mistaken for an EOA solely because `getCode` is empty. The special error above
is only for an address that had code at the pinned block.

For **this** IMDSeatStrategy, its operator approves the digest on-chain first;
its `isValidSignature` ignores the bytes argument. The actual request is still
the documented body, with the strategy in `message.wallet` and:

```json
{ "signature": "0x" }
```

`"0x"` is not a general ERC-1271 convention. Other contracts may require a
Safe/multisig signature blob or other bytes; pass it unchanged after normal hex
and size validation.

The page change is correspondingly small: retain the EOA `signTypedData` path;
when the selected owner has code, render the immutable typed data and a
contract-signature field. For IMDSeatStrategy show “call `authorizeWorker`
with this exact struct, wait for confirmation, then Continue,” prefill `0x`.
Do not let the page invent a different domain, nonce, or relay origin.

## 3. Mainnet test vectors

### Fixed negative vector (read-only, reproducible)

At Ethereum mainnet block **26,055,530** (`0x18d936a`,
`0x3f94d54b6fa1a046e69d57df445cde0ead7592a494a7886ddc98a8e3a9ea1c34`,
2026-09-25T16:02:35Z), public RPC `https://ethereum-rpc.publicnode.com`
returned `ownerOf(806)` and `ownerOf(1533)` as
`0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F`. The same pinned-state call
below returned `0xffffffff`. This is a deliberately unapproved digest; it did
not send a transaction or a pairing request.

```text
domain = {
  name: "IdentityMD Worker", version: "2", chainId: 1,
  verifyingContract: "0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D"
}
message = {
  deviceKey: "0x1111111111111111111111111111111111111111111111111111111111111111",
  wallet:    "0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F",
  tokenId:   806,
  nonce:     "0x2222222222222222222222222222222222222222222222222222222222222222",
  expiresAt: 1900000000,
  relayOrigin: "https://api.imd.fun"
}
domainSeparator = 0x6627a139a2f3d13e9457b45526362427670069dfdfe35c83771fe61cb9af6b2d
structHash      = 0xa73a9b388394a155aa79d24eb5f3aedc2c7e0e06d573ba13478f39bfdcb08c10
digest          = 0xc662bb464a661be9269b2c7bba3a4196ce5f570adad0763160e835af053aee59
```

The digest formula is EIP-712:

```text
keccak256(0x1901 || domainSeparator || structHash)
domainSeparator = keccak256(abi.encode(
  keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
  keccak256("IdentityMD Worker"), keccak256("2"), 1, collection))
structHash = keccak256(abi.encode(
  keccak256("WorkerAuthorization(bytes32 deviceKey,address wallet,uint256 tokenId,bytes32 nonce,uint64 expiresAt,string relayOrigin)"),
  deviceKey, wallet, tokenId, nonce, expiresAt, keccak256(bytes(relayOrigin))))
```

Exact read-only JSON-RPC request and response:

```json
{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F","data":"0x1626ba7ec662bb464a661be9269b2c7bba3a4196ce5f570adad0763160e835af053aee5900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000"},"0x18d936a"]}
{"jsonrpc":"2.0","id":1,"result":"0xffffffff00000000000000000000000000000000000000000000000000000000"}
```

The calldata is `isValidSignature(bytes32,bytes)` with `bytes="0x"`. The
requested result is the first four bytes, `0xffffffff`. Recompute rather than
reusing a digest after the shown block: ownership and implementation state can
change.

### Approved-path fork test

Use a mainnet fork at a recent block where the strategy owns token 806 or 1533.
This is a local test only; impersonation is never a mainnet action.

1. Obtain the current `seatOperator()` from the strategy and fork mainnet.
2. Impersonate that address locally; call `authorizeWorker` on the strategy
   with a fresh device key, `wallet = strategy`, a token it owns, a fresh nonce,
   the exact `/pair/start` relay origin, and `expiresAt > fork timestamp`.
3. Compute the same digest with `hashTypedData` / the constants above.
4. Assert `isValidSignature(digest, "0x") == 0x1626ba7e` and that
   `verifyWorkerAuthorization(message, "0x")` is true.
5. Transfer the NFT away on the fork (or use a test collection) and assert the
   call becomes `0xffffffff`. Also assert a one-bit message change, expired
   message, wrong wallet, reused nonce/code, and EOA invalid signature fail.

This matches the verified strategy source: authorization checks wallet, expiry,
and ownership before recording a digest, and validation checks ownership again
[D3]. It does not establish a live pairing and must not be run against the
production control plane.

## 4. Security review

| Finding / severity | Why it matters | Required mitigation |
|---|---|---|
| Malicious “valid for everything” ERC-1271 wallet — **Informational / holder trust** | ERC-1271 delegates authorization policy to the NFT owner contract. A contract that returns magic for all hashes authorizes its own seat; it does not authorize somebody else's NFT if IMD retains `ownerOf == wallet`. | State this trust boundary. Never accept magic without the owner check and exact typed data. |
| Replay across token, collection, chain, device, or service — **High if fields are not bound** | A digest valid for a different asset or pairing could enroll an unintended device. | Preserve domain name/version, chain ID and collection; preserve `wallet`, `tokenId`, device key, nonce, expiry, and relay origin exactly; compare them to the stored `/pair/start` record; atomically consume code+nonce. Never introduce a generic “contract approved this hash” bypass. |
| NFT sold after pairing — **High availability/authorization risk** | The old device should not retain the seat after ownership changes. A signature only proves a historical policy decision. | Ownership-check at pairing and on worker admission/heartbeat; revoke/disconnect immediately on failure. The IMDSeatStrategy's ERC-1271 additionally fails after transfer [D3], but IMD must not rely on that behavior for arbitrary contracts. |
| Reentrancy during verification — **Low for correct implementation** | `eth_call` is off-chain and cannot mutate IMD's database; ERC-1271 itself is specified view/static. | Do all DB effects after verification and commit conditional state once. Do not expose internal callback endpoints or use an on-chain transaction for validation. |
| Gas/CPU/RPC griefing or revert — **Medium availability** | An owner-selected contract can consume excessive gas, recurse, revert, or delay every pairing request. | 250,000 gas and 5,000 ms per signature call, no automatic retries; rate-limit endpoint; fail closed with `503`; observe timeouts. The cap is a service safety limit, not a promise that every arbitrary wallet is supported. |
| RPC lies/fails or a reorg occurs — **Medium** | A bad read can enroll a device without a dependable ownership check. | Use a trusted redundant RPC provider policy, pin reads to one recent block, require a canonical/fresh head, fail closed, record block number/hash, and recheck on connection. |
| Upgradeable contract policy changes — **Medium / holder trust** | A proxy can change who/what it approves after enrollment. | Treat approval as point-in-time and retain lifecycle ownership checks. Do not cache ERC-1271 success past the request. Consider recording wallet code hash/implementation only for audit, never as authorization. |
| Signature payload exhaustion — **Low** | Arbitrarily large `bytes` can amplify parsing/RPC cost. | Keep the current request-body limit and impose a documented signature maximum (e.g. 16 KiB); `0x` remains valid for this strategy. |
| Error oracle / enumeration — **Low** | Distinguishing no code, non-magic and reverts leaks wallet policy details. | Return the proposed contract-invalid message only after valid syntax and ownership checks; do not expose revert data. Keep detailed error telemetry server-side. |

## 5. Delegate.xyz v2 alternative

The alternative is an EOA delegate signature, checked against Delegate Registry
v2 at `0x00000000000000447e69651d841bD8D104Bed493` (the address supplied in
this assignment). The holder contract would delegate **that ERC-721 token** to
an EOA, and IMD would require the registry's token-specific delegation check
for `(delegate signer, vault/owner contract, collection, tokenId, rights)` at
the same pinned block as `ownerOf`.

It is **not** a zero-schema substitution. Existing data says
`message.wallet` is the NFT holder, but ECDSA recovers the delegate. Therefore
the server must explicitly define `signer` separately (or recover it) while
keeping `message.wallet = holder contract`; it must also bind the exact
delegation, ownership, code, nonce and expiry before consuming the code.
The holder contract needs a method/upgrade to make the registry delegation
transaction. IMDSeatStrategy as verified exposes no delegate-registry method
[D3], so it would need an upgrade or other owner-authorized execution path.

| Option | Effort | Main risk |
|---|---|---|
| ERC-1271 | One page branch and one verification branch; no message/API shape change; arbitrary wallets choose their approval scheme. | Holder contract defines authorization policy; bounded RPC call needed. |
| delegate.xyz v2 | New signer-vs-holder semantics, registry integration/tests, delegation lifecycle/revocation checks, and strategy support. | Accidentally accepting broad/wrong-rights delegation or retaining authority after revocation; expands dependence and UI. |

**Recommend ERC-1271.** It is the purpose-built standard [D4], makes the
holder contract the single authority, and IMDSTR already implements the exact
digest policy. Delegate.xyz can be a later, explicit “delegated EOA operator”
feature, not a workaround in `/pair/complete`.

## 6. IMDSeatStrategy runbook after release

All values must come from the one live `/pair/start` response; do not reuse an
old message. Commands below describe the intended operator workflow and do not
authorize a production transaction from this report.

1. On the worker machine run `imd pair`. It starts `/pair/start`, prints the
   code/page, and waits for enrollment (verified CLI behavior [D2]).
2. On the pairing page select the strategy address
   `0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F` and token 806 or 1533. Copy
   the exact WorkerAuthorization supplied by that code.
3. The configured `seatOperator` calls
   `authorizeWorker({deviceKey, wallet: strategy, tokenId, nonce, expiresAt,
   relayOrigin})`. Wait for confirmation before continuing. It must occur before
   `expiresAt` and while the strategy owns the token [D3].
4. Submit the normal `POST /pair/complete` body, unchanged except
   `message.wallet` is the strategy and `signature` is `"0x"`. Expect the
   same success shape; do not submit before step 3.
5. Fetch `GET /agents/register-intent?tokenId=<tokenId>`. The operator calls
   `registerAgent(tokenId, agentURI)` on the strategy with returned `agentURI`;
   this invokes IMD's adapter as the NFT owner [D3].
6. After the registration is visible, `POST /agents/bind` with the token and
   agent ID (and optionally transaction hash), as documented [D1]. Poll if it
   returns `pending: true`.
7. Run `imd start --auto-update` (or the desired runtime/concurrency flags).
   The worker docs say an unregistered token cannot connect [D2].
8. Confirm the seat record with
   `GET https://api.imd.fun/pair/wallet/0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F`;
   then check `GET /seats/<tokenId>` or `/workers` for online status. The
   `/pair/wallet/:address` purpose is documented; the latter status routes are
   public fleet reads [D1].

## Unanswered questions before merge

* Which exact server modules use the generic Wallet credential outside the
  published route table? Audit them; do not assume this pairing-only patch
  changes payment routes.
* Does the production RPC provider honor `eth_call.gas` for viem's universal
  verifier path, and does its error taxonomy distinguish timeout/revert? Add an
  integration test with a recording transport and an RPC fixture.
* At what cadence does the control plane recheck `ownerOf` for an already live
  worker, and what exact disconnect/revocation reason reaches the device? This
  needs a server-source test, not inference from the docs.
