# Investigation record: ERC-1271 pairing for IdentityMD seats

Observed 2026-09-25 UTC. **VERIFIED** means directly supported by the cited public source or recorded read. **SUSPECTED** means an inference about the unpublished control plane. **PROPOSED** is implementation advice, not a claim about current behavior. Addresses are checksummed where convenient; comparisons must be case-insensitive.

## Plain-language summary

IdentityMD's backend should keep every existing pairing check and replace EOA-only signature recovery with viem's `publicClient.verifyTypedData`. That action verifies EOAs and deployed ERC-1271 wallets (and also understands ERC-6492). For the strategy, the seat operator first approves the exact pairing digest on-chain, then submits the ordinary `/pair/complete` body with `wallet` set to the strategy and `signature: "0x"`. The strategy returns ERC-1271's magic value only for that approved digest while it still owns the named NFT.

The current web page also needs a small “contract wallet” path because it presently gets an injected EOA account and asks it to sign. Agent registration is already compatible at the protocol level: the strategy calls the adapter itself. A delegate.xyz alternative is broader, more stateful, and would require both a strategy upgrade/call path and new backend delegation semantics. ERC-1271 is the smaller and safer change.

The closed backend leaves three material unknowns: the exact current signature-verification implementation, how often ownership is rechecked after pairing, and where contributor rewards are ultimately paid. Maintainers should confirm these before merging.

## Evidence and limits

- **VERIFIED — deployed public version.** `GET https://api.imd.fun/version` at `2026-09-25T15:26:34Z` returned HTTP 200 and commit `af9d577e7ec5c4254935b9d30ac30ea07ac1a508`. The docs say this route names the deployed commit ([IMD API docs, introduction](https://imd.fun/docs)).
- **VERIFIED — identity configuration.** `GET https://api.imd.fun/health` at `2026-09-25T15:26:35Z` returned HTTP 200 with chain `1`, collection `0x0000ec93127baa929e58e97dd0095a2bfb38ec1d`, and adapter `0xde152afb7db5373f34876e1499fbd893a82dd336`. This was a public read only.
- **VERIFIED — worker source snapshot.** Public repository commit `cb75443ecdef1b13128cbf89891dfd62864cd4ca` was inspected. The repository states that backend source is not distributed ([README lines 177–184](https://github.com/Identity-md/worker/blob/cb75443ecdef1b13128cbf89891dfd62864cd4ca/README.md#L177-L184)). Consequently, assertions about backend internals are not possible from this source.
- **VERIFIED — pairing page snapshot.** `GET https://api.imd.fun/pair` was read at approximately `2026-09-25T15:23Z`; no code was supplied and no pairing action occurred. Line references below refer to the returned inline module, with the URL also supplied.
- **VERIFIED — chain snapshot.** Ethereum block `26055346`, timestamp `2026-09-25T15:25:23Z`, hash `0x1803aee531c10d04aba8c523df640eb81619012c7fbe3e5e8d9d11ca8f2db101`. The hash and timestamp agreed between Flashbots and PublicNode. All state reads below use this block.
- No POST was made to any live pairing or agent route; no other user's data was queried; no signing, transaction, payment, impersonation, or fork mutation was performed. The fork recipe below is instructions for maintainers, not an executed test.

## 1. EOA assumptions through the documented flow

| Surface | Finding | Contract holder status and reason |
|---|---|---|
| Worker CLI starts pairing | **VERIFIED.** `runPairing` POSTs only `{deviceKey}` to `/pair/start`, opens `/pair?code=...`, polls `/pair/:code`, and stores returned `wallet`/`tokenId`. The distributed file is minified on one line; the relevant function starts at byte offset 836,186 in `dist/cli.js` at commit `cb75443…`. The README says first start pairs “with the wallet that owns” the NFT ([README 63–76](https://github.com/Identity-md/worker/blob/cb75443ecdef1b13128cbf89891dfd62864cd4ca/README.md#L63-L76)). | **Not inherently blocked.** The CLI does not sign or require the wallet to be an EOA. The browser/backend steps do. |
| Pairing page chooses wallet | **VERIFIED.** The page requires `window.ethereum`, calls `eth_requestAccounts`, lowercases the returned account, and lists that address's tokens (captured inline module lines 203–239 and 249–270 at [`/pair`](https://api.imd.fun/pair)). | **Blocked by current UI.** A contract address is not normally an injected wallet account. There is no contract-address entry or “already authorized on-chain” path. |
| Pairing page signs | **VERIFIED.** It constructs the exact typed data below and calls `eth_signTypedData_v4` with `[wallet, JSON.stringify(typedData)]` (captured lines 377–426 at [`/pair`](https://api.imd.fun/pair)). | **Blocked by current UI.** It assumes the selected account can directly service the signing RPC. The strategy instead approves the digest through `authorizeWorker`; it is not an interactive browser signer. |
| Pair completion | **VERIFIED (external contract only).** Docs classify `POST /pair/complete` as `Wallet`, define `signature` as a wallet EIP-712 signature, promise `409` for consumed/enrolled and `503` when ownership is unreadable, and say codes expire after five minutes and complete once ([Pairing and agents](https://imd.fun/docs#pairing)). The page sends `{code, signature, message}` (captured lines 428–449). | **Currently blocked, per task context/maintainer statement; backend cause is SUSPECTED.** Most likely it uses EOA recovery or requires a 65-byte signature instead of ERC-1271. The unpublished verifier was not observed, and this assignment prohibited a live pairing attempt. |
| Token listing | **VERIFIED.** `GET /pair/wallet/:address` is public and lists seats/devices; the page permits manual token entry if listing fails. It explicitly says the completion-time signature and ownership check—not the list—are authoritative (captured lines 241–281). | **Not inherently blocked.** The endpoint accepts an address-shaped path and the page supports manual token IDs, but reaching this section currently requires connecting an injected account. |
| Completion ownership check | **VERIFIED at interface level.** Docs identify `message.wallet` as the seat holder, report `503 ownership unreadable`, and the page displays “Verifying ownership on-chain…” before completion ([Pairing and agents](https://imd.fun/docs#pairing); page lines 428–442). | **Should not be blocked.** `ownerOf(tokenId) == wallet` works identically for EOA and contract addresses. **SUSPECTED:** current backend calls `ownerOf` during completion; its exact ordering and block tag are unpublished. |
| Ownership after pairing | **VERIFIED indications, not implementation.** The worker protocol contains terminal reason `nft_transferred` in `dist/client.js` (one-line distribution; string at byte offset 729,356 at commit `cb75443…`). Public seat responses include `owner`, status and eligibility ([IMD docs, public reads](https://imd.fun/docs)). | **SUSPECTED:** the control plane periodically rechecks ownership and terminates/revokes eligibility after transfer. Frequency, confirmation depth, and whether enrollment is deleted are unanswered. Contract ownership itself does not impede `ownerOf`. |
| Agent registration | **VERIFIED.** The page asks the connected wallet to send adapter calldata with `eth_sendTransaction`; it says the adapter needs the token owner to send it (captured lines 456–517). Docs expose public register-intent and bind routes ([Pairing and agents](https://imd.fun/docs#pairing)). | **Blocked by current UI transaction button**, because `from` is the connected EOA. **Not blocked at protocol level:** the strategy has `registerAgent` and calls the same adapter as `msg.sender`; verified source lines 126–140 below. |
| Other `Wallet`-authenticated routes | **VERIFIED from published docs.** The auth glossary says `Wallet` means an EIP-712 signature from the wallet holding the seat. In the published route tables, `/pair/complete` is the only route visibly labelled `Wallet`; device routes use Ed25519 after pairing ([Authentication](https://imd.fun/docs), [Pairing](https://imd.fun/docs#pairing), [Signed device calls](https://imd.fun/docs#device)). | No additional documented seat-holder wallet route was found. **Unanswered:** unpublished/operator routes may exist. They need the same EOA/contract-aware verifier if they authenticate a holder signature. Do not change device signatures. |
| Paid request signatures | **VERIFIED.** Paid requests use a bearer request key, an x402 Permit2 payment signature, and a typed quote approval; docs say “the same wallet signs twice” and needs IMD balance/Permit2 allowance ([Requests, submit](https://imd.fun/docs)). | This is a **payer wallet**, not documented as the seat holder. It is outside seat pairing. ERC-1271 support here requires separate compatibility testing because Permit2/x402 may impose its own signature format; do not silently couple it to this change. |
| Earnings/payment to holder | **VERIFIED only as a read model.** `GET /wallets/:address/earnings` returns `{wallet,count,next,earnings}` ([IMD docs, public reads](https://imd.fun/docs)). The strategy source comments that IMD launch allocations earned by seats may be swept as ERC-20 (verified source lines 109–115), but comments are design intent, not proof of IMD payment behavior. | **Unknown.** Public docs inspected do not state settlement asset, recipient derivation, push/pull mechanism, or whether rewards go to current owner, pairing wallet, or another address. If rewards are transferred directly to the holder, ERC-20 transfers to a contract are ordinarily fine; native-ETH pushes can revert. Maintainers must identify this code path and ensure a reverting recipient cannot block batch settlement (prefer claim/pull or isolated per-recipient failures). |

## 2. Drop-in `/pair/complete` change

### Invariants to retain

**PROPOSED.** Parse with existing strict schemas; load the code without consuming it; require it exists, is unexpired, unused, and its stored `deviceKey`, nonce, relay origin, chain ID and NFT contract match the submitted message/domain; require `expiresAt > now` and within the existing allowed horizon; call `ownerOf(tokenId)` and require exact equality with `message.wallet`; verify the typed signature; atomically consume the code and enroll the token/device with the existing one-active-device and one-use constraints. A verification failure or RPC failure must not consume the code. Preserve existing `409` and `503` behavior.

The exact typed data, verified against the live page, is:

```ts
import type { Address, Hex } from 'viem'

class PairError extends Error {
  constructor(public status: number, public code: string, detail: string) {
    super(detail)
  }
}

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 PairMessage = {
  deviceKey: string       // API wire form: 64 lowercase hex, no 0x
  wallet: Address
  tokenId: string         // decimal string
  nonce: string           // API wire form: 64 hex, no 0x
  expiresAt: number
  relayOrigin: string
}

async function verifyWorkerAuthorization(args: {
  publicClient: { verifyTypedData: Function; getBytecode: Function }
  message: PairMessage
  signature: Hex
  chainId: number
  nftContract: Address
}) {
  const { publicClient, message, signature, chainId, nftContract } = args
  try {
    // Used only to select the precise rejection text; universal verification is
    // intentionally still one path for EOA and contract accounts.
    const isContract = (await publicClient.getBytecode({ address: message.wallet })) != null
    const valid = await publicClient.verifyTypedData({
      address: message.wallet,
      domain: {
        name: 'IdentityMD Worker',
        version: '2',
        chainId,
        verifyingContract: nftContract,
      },
      types: workerTypes,
      primaryType: 'WorkerAuthorization',
      message: {
        deviceKey: `0x${message.deviceKey}` as Hex,
        wallet: message.wallet,
        tokenId: BigInt(message.tokenId),
        nonce: `0x${message.nonce}` as Hex,
        expiresAt: BigInt(message.expiresAt),
        relayOrigin: message.relayOrigin,
      },
      signature,
    })
    if (!valid) throw new PairError(
      401,
      'invalid_wallet_signature',
      isContract
        ? 'The contract wallet did not authorize this worker.'
        : 'The wallet did not authorize this worker.',
    )
  } catch (error) {
    if (error instanceof PairError) throw error
    // RPC timeout, revert, malformed return, or unavailable chain: fail closed.
    throw new PairError(503, 'signature_verification_unavailable',
      'Wallet signature verification is temporarily unavailable.')
  }
}
```

Viem documents that the **public-client action** verifies EOAs, deployed ERC-1271 accounts, and ERC-6492 pre-deployed accounts; the similarly named utility is EOA-only ([viem `verifyTypedData` action](https://viem.sh/docs/actions/public/verifyTypedData), [EOA-only utility warning](https://viem.sh/docs/utilities/verifyTypedData)). Use the action shown above.

**Resource bound (PROPOSED):** apply a **200,000 gas** cap to the verification `eth_call` and a **3,000 ms wall-clock timeout**. Viem's action does not expose a per-call gas parameter because it calls a universal signature validator. Enforce the cap in the RPC transport/gateway by injecting `gas: 0x30d40` into this action's `eth_call`; configure viem HTTP transport `timeout: 3_000` (and no retries on the request path), or use an equivalent abort signal in the gateway. The cap is on the outer universal-validator call and therefore bounds its internal ERC-1271 call. If maintainers decide to support arbitrary ERC-6492 deployment simulations, measure them first: 200k may intentionally reject complex counterfactual wallets. Record a metric distinguishing false signature (`401`) from timeout/revert/RPC (`503`).

**Contract request body.** It is identical to the documented body. The strategy supplies no cryptographic bytes because its authorization is state, so send `"signature":"0x"`:

```json
{
  "code": "ABCD2345",
  "message": {
    "deviceKey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "wallet": "0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F",
    "tokenId": "806",
    "nonce": "PAIR_START_NONCE_AS_64_HEX_WITHOUT_0x",
    "expiresAt": 1790353523,
    "relayOrigin": "https://api.imd.fun"
  },
  "signature": "0x"
}
```

The current docs merely require `0x` hex and do not state a minimum signature length ([Pairing](https://imd.fun/docs#pairing)). Do not globally require 65 bytes. For an EOA, preserve the current 65-byte signature validation. For a code-bearing wallet, accept arbitrary bounded hex (recommend at most 8 KiB) and pass it unchanged. The explicit “contract says no” response should be HTTP `401`:

```json
{"error":"invalid_wallet_signature","detail":"The contract wallet did not authorize this worker."}
```

The pairing page needs either (a) an advanced form accepting contract address and token ID, displaying the exact message for its operator and then posting `signature: "0x"`, or (b) documented curl/SDK instructions. It must not pretend the connected seat-operator EOA is the NFT owner. The smallest afternoon implementation is (b), followed by UI work.

## 3. Mainnet vectors and fork test

### Pinned state

**VERIFIED at block 26055346.** Reads through `https://ethereum-rpc.publicnode.com` returned:

- EIP-1967 implementation slot `0x3608…2bbc` on strategy proxy `0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F`: `0x16d3f65b708883df042d98e1c7a49b32a33e2a14`.
- `collection()`: `0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D`.
- `seatOperator()`: `0x35dA9C0303507ddf708E87F2568EdDf12c47a059`.
- `owner()`: `0xBd3ed9F4AbD9946cA6F59C8F13A3EbebDE1EA29D`.
- `IMD_AGENT_ADAPTER()`: `0xde152AfB7db5373F34876E1499fbD893A82dD336`.
- Collection `ownerOf(806)` and `ownerOf(1533)` both returned the strategy proxy.
- Runtime code was present at proxy (121 bytes), implementation (13,077 bytes), adapter (163 bytes), and delegate.xyz registry (10,046 bytes).

The implementation's verified source is public on [Etherscan](https://etherscan.io/address/0x16d3f65b708883df042d98e1c7a49b32a33e2a14#code). In its `IMDSeatStrategy.sol`, source lines 32–47 define the domain/type hashes and magic values; 59–67 define the message; 95–107 restrict the operator; 126–157 implement registration/authorization and ownership/expiry checks; 177–217 compute the digest and implement ERC-1271.

### Concrete negative vector

At the pinned block, use:

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

`workerAuthorizationDigest(auth)` returned that digest. This raw call, with a 200k cap, returned the ABI-padded invalid value:

```text
eth_call block 0x18d92b2
to:   0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F
gas:  0x30d40
data: 0x1626ba7ee0a5892ae60385ee7fcd73c89fd0dc5134afd97f69a97f1eb1f75c8254bb999d00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000
result: 0xffffffff00000000000000000000000000000000000000000000000000000000
```

Maintainers can independently compute the digest with viem's `hashTypedData` using the exact object in section 2, or compare it to `workerAuthorizationDigest` at the proxy. The function selector `0x1626ba7e` in calldata is also ERC-1271's `isValidSignature(bytes32,bytes)` selector; validity requires the first four return bytes to equal the same magic value.

### Approved path on a local mainnet fork

**PROPOSED TEST; NOT EXECUTED.** Use a fresh nonce and expiry later than the fork timestamp. This sends only to the local Anvil fork:

```bash
anvil --fork-url "$ETH_RPC_URL" --fork-block-number 26055346

RPC=http://127.0.0.1:8545
STRATEGY=0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F
OPERATOR=0x35dA9C0303507ddf708E87F2568EdDf12c47a059

cast rpc --rpc-url "$RPC" anvil_impersonateAccount "$OPERATOR"
cast rpc --rpc-url "$RPC" anvil_setBalance "$OPERATOR" 0x8ac7230489e80000

# Use the exact tuple whose digest the test will verify; expiry must exceed fork block.timestamp.
AUTH='(0x1111111111111111111111111111111111111111111111111111111111111111,0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F,806,0x3333333333333333333333333333333333333333333333333333333333333333,1790353523,"https://api.imd.fun")'

DIGEST=$(cast call "$STRATEGY" \
  'workerAuthorizationDigest((bytes32,address,uint256,bytes32,uint64,string))(bytes32)' \
  "$AUTH" --rpc-url "$RPC")

# Local-fork transaction only.
cast send "$STRATEGY" \
  'authorizeWorker((bytes32,address,uint256,bytes32,uint64,string))(bytes32)' \
  "$AUTH" --from "$OPERATOR" --unlocked --rpc-url "$RPC"

cast call "$STRATEGY" 'isValidSignature(bytes32,bytes)(bytes4)' \
  "$DIGEST" 0x --gas-limit 200000 --rpc-url "$RPC"
# Expected: 0x1626ba7e

cast rpc --rpc-url "$RPC" anvil_stopImpersonatingAccount "$OPERATOR"
```

Also run: wrong digest -> `0xffffffff`; transfer token on the fork after approval -> `0xffffffff`; revoke -> `0xffffffff`; expired authorization -> `authorizeWorker` revert; wrong wallet/token -> revert; backend timeout/revert -> fail closed and code remains unused.

## 4. Security review

| Risk | Severity | Analysis and mitigation |
|---|---|---|
| Contract returns magic for every digest | Medium (design/trust), not an auth bypass | ERC-1271 defines the contract as the authority. A permissive wallet can authorize any device for NFTs it owns, just as a compromised EOA can sign anything. Keep `ownerOf(tokenId) == message.wallet`, show the wallet address clearly, log contract-wallet pairings, and document that wallet security is holder responsibility. Never infer ownership merely from magic return. |
| Replay to another token | Low if existing fields/checks remain; High if omitted | `tokenId`, wallet, nonce and device key are signed. Enforce the code's nonce/device, ownership for that exact token, one-use code, one active enrollment rules, and atomic consumption. Do not accept a signature over a reconstructed message that differs from the request. |
| Replay across collections/chains/control planes | Low with exact domain/message | Chain ID and collection are in the EIP-712 domain; `relayOrigin` is in the message. Require all three from server-side pairing state, not caller-selected values. Normalize/compare an exact canonical relay origin; do not accept lookalike/trailing-path variants. |
| Token sold after pairing | High authorization lifecycle risk | The old holder's device must stop receiving work/rewards. Preserve and verify current ownership rechecks, fail closed after a bounded grace period on RPC outage, mark `nft_transferred`, and require the new owner to re-pair. The strategy itself invalidates its ERC-1271 approval immediately after transfer, but that alone does not revoke an enrollment already stored off-chain. |
| ERC-1271 changes its answer later / proxy upgrade | Medium | Contract signatures are stateful. Verify at completion and record block number/hash and code hash for audit. Ownership rechecks remain authoritative for seat eligibility. Do not require later signature revalidation as the only liveness mechanism because legitimate contract state may change. |
| Reentrancy during signature check | Low in an off-chain server | `eth_call` cannot commit state, even if the called code attempts stateful/reentrant calls. Keep verification outside any database transaction/lock and never expose internal callbacks. Treat all returned bytes as untrusted. |
| Gas/CPU griefing, revert bombs, huge return data | Medium availability | Cap at 200k gas and 3s, bound submitted signature (suggest 8 KiB) and relay string per existing schema, bound response decoding, rate-limit pairing attempts, and avoid retries. Return `503` for infrastructure/revert exhaustion, without consuming the code. |
| RPC failure or inconsistent head | Medium | Fail closed. Keep code unused so the holder can retry. Use a configured mainnet client, confirm chain ID 1, and perform signature verification and `ownerOf` against the same explicit block when practical. Do not fall back to EOA recovery merely because ERC-1271 failed. |
| EOA regression | High if verification path changes incorrectly | Add existing valid/invalid EOA fixtures and malleability cases. Use the public-client action for both paths; retain strict message/domain checks and current errors. Detect code only for UX/metrics, not to weaken checks. |
| ERC-6492 side effects/complexity | Medium | Viem simulates validation via `eth_call`; no state persists, but counterfactual deployment paths are more complex and may exceed the cap. If not required now, explicitly reject ERC-6492-wrapped signatures or test supported factories. If advertised, add dedicated vectors rather than assuming ERC-1271 coverage proves it. |
| Signature magic parsing | Medium | Require a successful call and exactly the standard first four return bytes `0x1626ba7e` under viem's validator behavior. Revert, empty, short, malformed, `0xffffffff`, and timeout are invalid/fail-closed. |
| Expiry/time-of-check race | Low | Check server time before chain calls and again before committing; require `expiresAt > now`. Keep existing maximum lifetime. The strategy checks expiry only when approving, while its `isValidSignature` does not; therefore the backend expiry check is essential. |
| Code/nonce race and duplicate completion | Medium | Consume using a single conditional database update after all external reads: `WHERE consumed=false AND expires_at>now`. Enrollment uniqueness should be enforced by database constraints/transaction. One winner; others return existing `409`. |
| Contract destruction/code disappearance | Low on modern Ethereum but possible via upgrade/delegation changes | Verification fails closed. Ownership rechecks still work. Do not cache “is contract” indefinitely; query at verification time or simply use the universal action. |

## 5. Delegate.xyz v2 alternative

**VERIFIED.** Delegate.xyz's public repository lists the v2 registry at `0x00000000000000447e69651d841bD8D104Bed493` on Ethereum and exposes `delegateERC721(to, contract_, tokenId, rights, enable)` and `checkDelegateForERC721(to, from, contract_, tokenId, rights)` ([delegate registry README/source](https://github.com/delegatexyz/delegate-registry)). Runtime code was present at the pinned block.

Possible design: the contract owner delegates token 806 to an operator EOA with a dedicated nonzero right such as `keccak256("identitymd.worker.pairing.v1")`. `/pair/complete` accepts the EOA signature but treats `message.wallet` as the contract owner, then checks:

```text
ownerOf(tokenId) == contractWallet
checkDelegateForERC721(signingEOA, contractWallet, collection, tokenId, RIGHT) == true
```

The signed schema would need to name the delegate or the backend would need an unambiguous signer field. Reusing `wallet` for the delegate would break the ownership equality; silently recovering a delegate not present in the message makes auditing and replay reasoning worse. Thus this is not actually “no signature-schema/backend work.”

For this strategy specifically, the verified `IMDSeatStrategy.sol` exposes no generic `execute` or delegate-registry method. **SUSPECTED/architectural conclusion:** it would require a UUPS implementation upgrade adding a tightly scoped delegation call (or some other owner-controlled call facility), then an on-chain delegation transaction. That is materially more work and upgrade risk than using the ERC-1271 functionality already deployed.

| | ERC-1271 | delegate.xyz v2 |
|---|---|---|
| Control-plane effort | Small: one standard verifier action plus resource bounds/tests | Medium: registry reads, rights policy, signer-vs-owner schema, revocation and precedence rules |
| Strategy effort | Already implemented | Upgrade/call path plus delegation transaction and lifecycle |
| Trust/dependency | Holder contract only | Holder contract + external registry availability/semantics + delegate EOA |
| Scope | Exact pre-approved digest; strategy keeps token ownership | Standing delegate permission until revoked; easier operator UX but broader window |
| Failure modes | Stateful ERC-1271, gas/reverts | Stale/revoked delegation, rights confusion, registry/RPC failure, EOA compromise |
| General ecosystem support | Standard wallet-signature path, already supported by viem | App-specific integration |

**Recommendation (PROPOSED): implement ERC-1271.** It matches the ownership model, requires no strategy upgrade, preserves the signed wallet/token binding, and benefits all compliant contract wallets. Consider delegate.xyz later as an explicit separate feature, with a dedicated typed-data version/right and threat model—not as a fallback when ERC-1271 returns false.

## 6. Strategy runbook after support

This is a runbook, not a record of actions performed.

1. On the worker host, run `imd pair` (or first `imd start` if that is the released UX). Record the code, `deviceKey`, nonce, expiry, relay origin, chain ID, and NFT contract returned by `/pair/start`. The public worker currently opens the URL and polls; it does not sign.
2. Select token 806 or 1533 and construct the exact `WorkerAuthorization`: prefix bytes32 fields with `0x` only for ABI/EIP-712, set `wallet` to the strategy proxy, use the decimal token ID, copy nonce and relay origin exactly, and choose an expiry accepted by the live code. Note the current page chooses `now + 900s` even though docs say the pairing code lasts five minutes; finish before the pairing code expires.
3. From the authorized `seatOperator`, call `authorizeWorker(auth)` on the strategy. Wait for confirmation. Confirm the emitted digest equals viem `hashTypedData`/`workerAuthorizationDigest(auth)` and that `isValidSignature(digest, 0x)` returns `0x1626ba7e`. This is the only pre-pairing mainnet transaction and must be initiated by the strategy operator, not IMD.
4. POST the exact documented body to `/pair/complete`, with bare hex for wire `deviceKey`/`nonce`, strategy proxy as wallet, and `signature: "0x"`. Expect `{deviceKey,wallet,tokenId,agentId}` or the existing errors. Never regenerate/alter expiry or origin after step 3.
5. If no agent is already bound, fetch `GET /agents/register-intent?tokenId=<id>` and extract `agentURI`. Call the strategy's `registerAgent(tokenId, agentURI)` as seat operator. Do **not** use the page's EOA `eth_sendTransaction` path; the strategy must be `msg.sender` to the adapter.
6. After confirmation, POST `/agents/bind` with `{tokenId, agentId?, txHash?}`. The docs say `agentId`/`txHash` are hints and the chain is checked; retry/poll if `{pending:true}` ([Pairing](https://imd.fun/docs#pairing)).
7. On the worker host run `imd start --auto-update` (plus the desired runtime/concurrency). The README says an unregistered token cannot connect and no inbound port is needed ([README 63–76](https://github.com/Identity-md/worker/blob/cb75443ecdef1b13128cbf89891dfd62864cd4ca/README.md#L63-L76)).
8. Check `GET /pair/wallet/0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F?fresh=1`. Confirm the token's device points to this device, `online` is true, and an `agentId` is present. Also check `imd status` and, if needed, `GET /seats/<tokenId>/standing`. The docs impose a 30-second minimum on fresh wallet refreshes; do not poll faster ([Pairing](https://imd.fun/docs#pairing)).

Operational cautions: authorize only the exact short-lived message; never expose the worker private device key; revoke the digest if pairing is abandoned; if the token is transferred, expect the seat to become ineligible and pair again under the new holder.

## 7. Precise open questions for maintainers

1. **Backend verifier:** Does current `/pair/complete` use viem's EOA-only utility, `recoverTypedDataAddress`, or a fixed 65-byte schema? This determines the exact diff and whether `"0x"` currently fails parsing before verification.
2. **Transaction ordering:** Is the pairing code marked consumed before or after signature/ownership checks? It should be consumed only in the final atomic enrollment commit.
3. **Block consistency:** Are `ownerOf` and contract-signature verification pinned to the same block? What confirmation policy is intended?
4. **Ongoing ownership:** What is the cadence, outage grace period, and state transition behind `nft_transferred`? Does a transfer immediately stop dispatch and earnings attribution?
5. **Reward recipient:** Is value paid to current `ownerOf`, the enrollment wallet snapshot, agent owner, or a separately configured recipient? Is it ERC-20, native ETH, accounting-only, push, or claim?
6. **Other wallet-auth routes:** Are any unpublished routes sharing the same signature helper? Audit all call sites before changing it so ERC-1271 support is deliberate rather than accidental.
7. **ERC-6492 scope:** Viem supports it, but does IMD want to promise it? If yes, raise/measure the gas cap and add counterfactual-account tests; if no, explicitly reject wrapped signatures.
8. **UI ownership:** Will maintainers ship a contract-wallet form, or initially document a direct API workflow? Backend support alone will not make the existing button usable for this strategy.

## Source index

- [IdentityMD API documentation](https://imd.fun/docs), especially “Authentication,” “Pairing and agents,” “Signed device calls,” paid requests, and public reads; fetched 2026-09-25 UTC.
- [Live IdentityMD pairing page](https://api.imd.fun/pair), inline JavaScript captured read-only 2026-09-25 UTC.
- [IdentityMD worker repository at inspected commit](https://github.com/Identity-md/worker/tree/cb75443ecdef1b13128cbf89891dfd62864cd4ca).
- [IMDSeatStrategy verified implementation source](https://etherscan.io/address/0x16d3f65b708883df042d98e1c7a49b32a33e2a14#code), fetched 2026-09-25 UTC.
- [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) and [EIP-712](https://eips.ethereum.org/EIPS/eip-712).
- [viem public-client `verifyTypedData`](https://viem.sh/docs/actions/public/verifyTypedData).
- [delegate.xyz v2 registry](https://github.com/delegatexyz/delegate-registry).
