# 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[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=`. 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/` 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.