diff --git a/.changeset/irs-onboard-dual-management.md b/.changeset/irs-onboard-dual-management.md new file mode 100644 index 0000000..c2ca29d --- /dev/null +++ b/.changeset/irs-onboard-dual-management.md @@ -0,0 +1,15 @@ +--- +'@openzeppelin/adapter-evm': minor +--- + +Deploy ONCHAINID identities with operator MANAGEMENT so the onboarding saga can complete. + +`deployOnchainId` now calls IdFactory `createIdentityWithManagementKeys` instead of `createIdentity`, granting MANAGEMENT to a configured operator key while wallet-linking the holder. Every future identity's key layout changes: the holder is linked but does not self-manage until `grantHolderManagementKey` runs. + +**Consumer-visible behaviour change (new required construction input).** `createIRS` now requires `operatorManagementKey` — the EOA that will execute `attachClaim` in the saga. It must be explicit; do not infer it from the transaction signer, because the IdFactory `onlyOwner` caller may be a relayer contract. Missing or malformed values throw `InvalidOperatorManagementKeyError` at construction (same discipline as `InvalidDeployReceiptWaitError`). + +**New write: `grantHolderManagementKey`.** Submits `addKey(holder, MANAGEMENT)` on the deployed identity. Consumers must call it after `deployOnchainId` and before `attachClaim` — that ordering is load-bearing for partial-failure resilience (holder can rescue their identity if a later step fails). + +**Saga order:** `deployOnchainId` → `grantHolderManagementKey` → `attachClaim` → `registerIdentity`. + +Pass `operatorManagementKey` at `createIRS` construction with the saga operator's EOA address (the same address that signs `attachClaim` transactions). diff --git a/packages/adapter-evm-core/src/__tests__/ri-sc004-coverage.test.ts b/packages/adapter-evm-core/src/__tests__/ri-sc004-coverage.test.ts index 286ae78..0289b0d 100644 --- a/packages/adapter-evm-core/src/__tests__/ri-sc004-coverage.test.ts +++ b/packages/adapter-evm-core/src/__tests__/ri-sc004-coverage.test.ts @@ -10,7 +10,7 @@ * mint, burn, transfer, freeze, unfreeze * - ERC-4626: convertToAssets, convertToShares, totalAssets; deposit, withdraw * - IRS: getOnchainId, isVerified, getJurisdiction; - * deployOnchainId, registerTrustedIssuer, attachClaim, registerIdentity + * deployOnchainId, grantHolderManagementKey, registerTrustedIssuer, attachClaim, registerIdentity */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; @@ -40,7 +40,13 @@ const SC004_COVERAGE: Record = { }, irs: { reads: ['getOnchainId', 'isVerified', 'getJurisdiction'], - writes: ['deployOnchainId', 'registerTrustedIssuer', 'attachClaim', 'registerIdentity'], + writes: [ + 'deployOnchainId', + 'grantHolderManagementKey', + 'registerTrustedIssuer', + 'attachClaim', + 'registerIdentity', + ], testDirs: ['src/irs/__tests__'], }, }; diff --git a/packages/adapter-evm-core/src/capabilities/index.ts b/packages/adapter-evm-core/src/capabilities/index.ts index c6cdf8c..ed1c229 100644 --- a/packages/adapter-evm-core/src/capabilities/index.ts +++ b/packages/adapter-evm-core/src/capabilities/index.ts @@ -5,7 +5,7 @@ export { createERC3643, type CreateERC3643Options } from './erc3643'; export { createERC4626, type CreateERC4626Options } from './erc4626'; export { createExecution } from './execution'; export { createExplorer } from './explorer'; -export { createIRS, type CreateIRSOptions } from './irs'; +export { createIRS, type CreateIRSOptions, type EvmIRSCapability } from './irs'; export { createNameResolution, type CreateNameResolutionOptions } from './name-resolution'; export { createNetworkCatalog } from './network-catalog'; export { createQuery } from './query'; diff --git a/packages/adapter-evm-core/src/capabilities/irs.ts b/packages/adapter-evm-core/src/capabilities/irs.ts index 0d28bf0..ab12f65 100644 --- a/packages/adapter-evm-core/src/capabilities/irs.ts +++ b/packages/adapter-evm-core/src/capabilities/irs.ts @@ -1,7 +1,16 @@ -import type { IRSCapability, NetworkConfig } from '@openzeppelin/ui-types'; +import type { + ExecutionConfig, + IRSCapability, + NetworkConfig, + OperationResult, + TransactionStatusUpdate, + TxStatus, +} from '@openzeppelin/ui-types'; import { createEvmIRSService } from '../irs'; import type { DeployReceiptWaitOptions, EvmIRSAddresses } from '../irs'; +import { assertValidOperatorManagementKey } from '../irs/management-key'; +import type { FactoryIdentityLookup } from '../irs/onchain-reader'; import { adaptSignAndBroadcast, assertValidAddress, @@ -21,6 +30,13 @@ import type { SignAndBroadcast } from './helpers'; export interface CreateIRSOptions { signAndBroadcast: SignAndBroadcast; addresses: EvmIRSAddresses; + /** + * Address that receives MANAGEMENT on deploy and executes `attachClaim` in the onboarding saga. + * + * Must be the operator EOA — never inferred from the transaction signer, because the IdFactory + * `onlyOwner` caller may be a relayer contract. + */ + operatorManagementKey: string; trustedIssuer?: string; /** * Bounds on the `deployOnchainId` confirmation wait (confirmations + timeout). @@ -33,6 +49,20 @@ export interface CreateIRSOptions { export type { EvmIRSAddresses } from '../irs'; +/** + * EVM IRS capability surface, including adapter extensions not yet on the shared + * {@link IRSCapability} contract in `@openzeppelin/ui-types`. + */ +export interface EvmIRSCapability extends IRSCapability { + getFactoryIdentity(holder: string): Promise; + grantHolderManagementKey( + input: { onchainId: string; holder: string }, + executionConfig: ExecutionConfig, + onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void, + runtimeApiKey?: string + ): Promise; +} + /** * Create the EVM IRS / ONCHAINID capability. * @@ -40,7 +70,7 @@ export type { EvmIRSAddresses } from '../irs'; * `signAndBroadcast` into the service's executor, and wraps the result with * `guardRuntimeCapability` for the `RuntimeCapability` surface and idempotent `dispose()`. */ -export function createIRS(config: NetworkConfig, options: CreateIRSOptions): IRSCapability { +export function createIRS(config: NetworkConfig, options: CreateIRSOptions): EvmIRSCapability { const networkConfig = asTypedEvmNetworkConfig(config); assertValidAddress('addresses.identityRegistry', options.addresses.identityRegistry); assertValidAddress('addresses.identityFactory', options.addresses.identityFactory); @@ -48,12 +78,14 @@ export function createIRS(config: NetworkConfig, options: CreateIRSOptions): IRS if (options.trustedIssuer !== undefined) { assertValidAddress('trustedIssuer', options.trustedIssuer); } + assertValidOperatorManagementKey(options.operatorManagementKey); const service = createEvmIRSService( networkConfig, adaptSignAndBroadcast(options.signAndBroadcast), { addresses: options.addresses, trustedIssuer: options.trustedIssuer, + operatorManagementKey: options.operatorManagementKey, deployReceiptWait: options.deployReceiptWait, } ); @@ -64,5 +96,5 @@ export function createIRS(config: NetworkConfig, options: CreateIRSOptions): IRS 'irs', () => service.dispose(), 'general' - ) as unknown as IRSCapability; + ) as unknown as EvmIRSCapability; } diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.factory-read.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.factory-read.test.ts index eb738e2..bb58e7b 100644 --- a/packages/adapter-evm-core/src/irs/__tests__/irs.factory-read.test.ts +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.factory-read.test.ts @@ -27,6 +27,8 @@ const ZERO = '0x0000000000000000000000000000000000000000'; const REGISTRY = '0x1111111111111111111111111111111111111111'; const TRUSTED_ISSUERS = '0x3333333333333333333333333333333333333333'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; + function makeCapability(): { capability: IRSCapability } { const options: CreateIRSOptions = { signAndBroadcast: vi.fn(), @@ -35,6 +37,7 @@ function makeCapability(): { capability: IRSCapability } { identityFactory: FACTORY, trustedIssuersRegistry: TRUSTED_ISSUERS, }, + operatorManagementKey: OPERATOR, }; const capability = createIRS( { diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.factory.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.factory.test.ts index 8bce726..2c6a854 100644 --- a/packages/adapter-evm-core/src/irs/__tests__/irs.factory.test.ts +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.factory.test.ts @@ -6,10 +6,10 @@ */ import { describe, expect, it, vi } from 'vitest'; -import type { IRSCapability } from '@openzeppelin/ui-types'; import { RuntimeDisposedError } from '@openzeppelin/ui-types'; -import { createIRS, type CreateIRSOptions } from '../../capabilities/irs'; +import { createIRS, type CreateIRSOptions, type EvmIRSCapability } from '../../capabilities/irs'; +import { InvalidOperatorManagementKeyError } from '../management-key'; const mockNetworkConfig = { id: 'evm-testnet', @@ -24,6 +24,8 @@ const mockNetworkConfig = { nativeCurrency: { name: 'Test Ether', symbol: 'TETH', decimals: 18 }, } as const; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; + function makeOptions(overrides: Partial = {}): CreateIRSOptions { return { signAndBroadcast: vi.fn().mockResolvedValue({ txHash: '0xtx' }), @@ -32,19 +34,21 @@ function makeOptions(overrides: Partial = {}): CreateIRSOption identityFactory: '0x2222222222222222222222222222222222222222', trustedIssuersRegistry: '0x3333333333333333333333333333333333333333', }, + operatorManagementKey: OPERATOR, ...overrides, }; } describe('createIRS', () => { it('creates an IRS capability with the expected method surface', () => { - const capability: IRSCapability = createIRS(mockNetworkConfig, makeOptions()); + const capability: EvmIRSCapability = createIRS(mockNetworkConfig, makeOptions()); expect(typeof capability.getOnchainId).toBe('function'); expect(typeof capability.isVerified).toBe('function'); expect(typeof capability.getJurisdiction).toBe('function'); expect(typeof capability.buildClaimPayload).toBe('function'); expect(typeof capability.deployOnchainId).toBe('function'); + expect(typeof capability.grantHolderManagementKey).toBe('function'); expect(typeof capability.registerTrustedIssuer).toBe('function'); expect(typeof capability.attachClaim).toBe('function'); expect(typeof capability.registerIdentity).toBe('function'); @@ -73,6 +77,19 @@ describe('createIRS', () => { ).toThrow(/Invalid trustedIssuer/i); }); + it('throws for a missing operatorManagementKey', () => { + const { operatorManagementKey: _removed, ...rest } = makeOptions(); + expect(() => createIRS(mockNetworkConfig, rest as CreateIRSOptions)).toThrow( + InvalidOperatorManagementKeyError + ); + }); + + it('throws for an invalid operatorManagementKey', () => { + expect(() => + createIRS(mockNetworkConfig, makeOptions({ operatorManagementKey: 'not-an-address' })) + ).toThrow(InvalidOperatorManagementKeyError); + }); + it('disposes idempotently and guards access afterwards', () => { const capability = createIRS(mockNetworkConfig, makeOptions()); diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.onboard-management-keys.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.onboard-management-keys.test.ts new file mode 100644 index 0000000..413b56f --- /dev/null +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.onboard-management-keys.test.ts @@ -0,0 +1,259 @@ +/** + * ONCHAINID dual-management onboarding (deploy operator key, then grant holder key). + * + * RED-FIRST: these tests fail against `createIdentity` deploy calldata and against the + * absence of `grantHolderManagementKey` until the management-key layout is implemented. + */ +import { encodeAbiParameters, encodeEventTopics, keccak256 } from 'viem'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExecutionConfig } from '@openzeppelin/ui-types'; +import { IdentityOperationFailed } from '@openzeppelin/ui-types'; + +import { createIRS, type CreateIRSOptions, type EvmIRSCapability } from '../../capabilities/irs'; +import { ID_FACTORY_EVENTS_ABI } from '../abis'; +import { IDENTITY_KEY_PURPOSE_MANAGEMENT } from '../identity-keys'; +import { InvalidOperatorManagementKeyError } from '../management-key'; + +const mockReadContract = vi.fn(); +const mockGetTransactionReceipt = vi.fn(() => + Promise.reject(new Error('TransactionReceiptNotFoundError: not mined yet')) +); +const mockWaitForTransactionReceipt = vi.fn(); + +vi.mock('viem', async () => { + const actual = await vi.importActual('viem'); + return { + ...actual, + createPublicClient: vi.fn(() => ({ + readContract: mockReadContract, + getTransactionReceipt: mockGetTransactionReceipt, + waitForTransactionReceipt: mockWaitForTransactionReceipt, + })), + http: vi.fn((url: string) => ({ url, type: 'http' })), + }; +}); + +const EXEC_CONFIG = { method: 'eoa' } as unknown as ExecutionConfig; + +const ADDRESSES = { + identityRegistry: '0x1111111111111111111111111111111111111111', + identityFactory: '0x2222222222222222222222222222222222222222', + trustedIssuersRegistry: '0x3333333333333333333333333333333333333333', +} as const; + +const HOLDER = '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; +const ONCHAINID = '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'; +const TX_HASH = '0xtx'; + +function addressKeyHash(address: string): `0x${string}` { + return keccak256(encodeAbiParameters([{ type: 'address' }], [address as `0x${string}`])); +} + +function walletLinkedReceipt() { + const topics = encodeEventTopics({ + abi: ID_FACTORY_EVENTS_ABI, + eventName: 'WalletLinked', + args: { wallet: HOLDER, identity: ONCHAINID }, + }); + return { + status: 'success' as const, + logs: [ + { + address: ADDRESSES.identityFactory, + topics, + data: '0x' as const, + blockHash: '0x0', + blockNumber: 1n, + logIndex: 0, + transactionHash: TX_HASH, + transactionIndex: 0, + removed: false, + }, + ], + }; +} + +function makeCapability(): { + capability: EvmIRSCapability; + signAndBroadcast: ReturnType; +} { + const signAndBroadcast = vi.fn().mockResolvedValue({ txHash: TX_HASH }); + const options: CreateIRSOptions = { + signAndBroadcast, + addresses: { ...ADDRESSES }, + operatorManagementKey: OPERATOR, + }; + const capability = createIRS( + { + id: 'evm-testnet', + exportConstName: 'evmTestnet', + name: 'EVM Testnet', + ecosystem: 'evm', + network: 'ethereum', + type: 'testnet', + isTestnet: true, + chainId: 11155111, + rpcUrl: 'https://rpc.example.com', + nativeCurrency: { name: 'Test Ether', symbol: 'TETH', decimals: 18 }, + } as never, + options + ); + return { capability, signAndBroadcast }; +} + +describe('operatorManagementKey construction', () => { + it('rejects a missing operatorManagementKey at construction', () => { + expect(() => + createIRS( + { + id: 'evm-testnet', + exportConstName: 'evmTestnet', + name: 'EVM Testnet', + ecosystem: 'evm', + network: 'ethereum', + type: 'testnet', + isTestnet: true, + chainId: 11155111, + rpcUrl: 'https://rpc.example.com', + nativeCurrency: { name: 'Test Ether', symbol: 'TETH', decimals: 18 }, + } as never, + { + signAndBroadcast: vi.fn(), + addresses: { ...ADDRESSES }, + } as CreateIRSOptions + ) + ).toThrow(InvalidOperatorManagementKeyError); + }); + + it('rejects a malformed operatorManagementKey at construction', () => { + expect(() => + createIRS( + { + id: 'evm-testnet', + exportConstName: 'evmTestnet', + name: 'EVM Testnet', + ecosystem: 'evm', + network: 'ethereum', + type: 'testnet', + isTestnet: true, + chainId: 11155111, + rpcUrl: 'https://rpc.example.com', + nativeCurrency: { name: 'Test Ether', symbol: 'TETH', decimals: 18 }, + } as never, + { + signAndBroadcast: vi.fn(), + addresses: { ...ADDRESSES }, + operatorManagementKey: 'not-an-address', + } + ) + ).toThrow(InvalidOperatorManagementKeyError); + }); +}); + +describe('deployOnchainId management keys', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it('deploys via createIdentityWithManagementKeys with the configured operator key', async () => { + mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockResolvedValueOnce(true); + const { capability, signAndBroadcast } = makeCapability(); + + await capability.deployOnchainId({ holder: HOLDER }, EXEC_CONFIG); + + const action = signAndBroadcast.mock.calls[0][0]; + expect(action.functionName).toBe('createIdentityWithManagementKeys'); + expect(action.address.toLowerCase()).toBe(ADDRESSES.identityFactory); + expect(action.args[0]).toBe(HOLDER); + expect(action.args[2]).toEqual([addressKeyHash(OPERATOR)]); + }); + + it('gives the operator MANAGEMENT on the deployed identity (keyHasPurpose)', async () => { + mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockResolvedValueOnce(true); + const { capability } = makeCapability(); + + await capability.deployOnchainId({ holder: HOLDER }, EXEC_CONFIG); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: ONCHAINID, + functionName: 'keyHasPurpose', + args: [addressKeyHash(OPERATOR), BigInt(IDENTITY_KEY_PURPOSE_MANAGEMENT)], + }) + ); + }); + + it('maps post-deploy keyHasPurpose RPC failure to IdentityOperationFailed with onchainId', async () => { + mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockRejectedValueOnce(new Error('rpc down')); + const { capability } = makeCapability(); + + const error = await capability + .deployOnchainId({ holder: HOLDER }, EXEC_CONFIG) + .then(() => undefined) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(IdentityOperationFailed); + expect((error as IdentityOperationFailed).message).toContain(ONCHAINID); + expect((error as IdentityOperationFailed).message).toMatch(/could not verify/i); + }); +}); + +describe('grantHolderManagementKey', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it('submits addKey(holder, MANAGEMENT) on the identity', async () => { + mockReadContract.mockResolvedValueOnce(true); + const { capability, signAndBroadcast } = makeCapability(); + + await capability.grantHolderManagementKey( + { onchainId: ONCHAINID, holder: HOLDER }, + EXEC_CONFIG + ); + + const action = signAndBroadcast.mock.calls[0][0]; + expect(action.functionName).toBe('addKey'); + expect(action.address).toBe(ONCHAINID); + expect(action.args).toEqual([ + addressKeyHash(HOLDER), + BigInt(IDENTITY_KEY_PURPOSE_MANAGEMENT), + 1n, + ]); + }); + + it('gives the holder MANAGEMENT on the identity after grant (keyHasPurpose)', async () => { + mockReadContract.mockResolvedValueOnce(true); + const { capability } = makeCapability(); + + await capability.grantHolderManagementKey( + { onchainId: ONCHAINID, holder: HOLDER }, + EXEC_CONFIG + ); + + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: ONCHAINID, + functionName: 'keyHasPurpose', + args: [addressKeyHash(HOLDER), BigInt(IDENTITY_KEY_PURPOSE_MANAGEMENT)], + }) + ); + }); + + it('maps post-grant keyHasPurpose RPC failure to IdentityOperationFailed with onchainId', async () => { + mockReadContract.mockRejectedValueOnce(new Error('rpc down')); + const { capability } = makeCapability(); + + const error = await capability + .grantHolderManagementKey({ onchainId: ONCHAINID, holder: HOLDER }, EXEC_CONFIG) + .then(() => undefined) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(IdentityOperationFailed); + expect((error as IdentityOperationFailed).message).toContain(ONCHAINID); + expect((error as IdentityOperationFailed).message).toMatch(/could not verify/i); + }); +}); diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-identity.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-identity.test.ts index f807f08..d915bc5 100644 --- a/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-identity.test.ts +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-identity.test.ts @@ -52,6 +52,7 @@ const ADDRESSES = { } as const; const HOLDER = '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; const ONCHAINID = '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'; const TX_HASH = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; @@ -85,7 +86,11 @@ function makeCapability(): { signAndBroadcast: ReturnType; } { const signAndBroadcast = vi.fn().mockResolvedValue({ txHash: TX_HASH }); - const options: CreateIRSOptions = { signAndBroadcast, addresses: { ...ADDRESSES } }; + const options: CreateIRSOptions = { + signAndBroadcast, + addresses: { ...ADDRESSES }, + operatorManagementKey: OPERATOR, + }; const capability = createIRS( { id: 'evm-testnet', @@ -108,10 +113,9 @@ describe('deployOnchainId receipt resolution', () => { beforeEach(() => vi.clearAllMocks()); afterEach(() => vi.restoreAllMocks()); - it('resolves onchainId from WalletLinked in the receipt — NOT from getIdentity eth_call', async () => { + it('resolves onchainId from WalletLinked in the receipt — NOT from factory getIdentity eth_call', async () => { mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); - // If deployOnchainId still eth_calls getIdentity, this poisoned return would win. - mockReadContract.mockResolvedValueOnce('0x0000000000000000000000000000000000000000'); + mockReadContract.mockResolvedValueOnce(true); // operator MANAGEMENT probe on identity const { capability, signAndBroadcast } = makeCapability(); @@ -119,13 +123,18 @@ describe('deployOnchainId receipt resolution', () => { expect(result).toEqual({ id: TX_HASH, onchainId: ONCHAINID }); expect(signAndBroadcast).toHaveBeenCalledOnce(); - expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockReadContract).toHaveBeenCalledOnce(); + expect(mockReadContract.mock.calls[0]?.[0]).toMatchObject({ + functionName: 'keyHasPurpose', + address: ONCHAINID, + }); }); it('WAITS for confirmation — never a point-in-time getTransactionReceipt', async () => { // getTransactionReceipt rejects (pending), waitForTransactionReceipt resolves. A // check-instead-of-wait implementation cannot pass this. mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockResolvedValueOnce(true); const { capability } = makeCapability(); const result = await capability.deployOnchainId({ holder: HOLDER }, EXEC_CONFIG); @@ -137,6 +146,7 @@ describe('deployOnchainId receipt resolution', () => { it('bounds the wait — passes a confirmations count AND a finite timeout', async () => { mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockResolvedValueOnce(true); const { capability } = makeCapability(); await capability.deployOnchainId({ holder: HOLDER }, EXEC_CONFIG); diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-wait-bounds.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-wait-bounds.test.ts index d966a08..a7e0e8a 100644 --- a/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-wait-bounds.test.ts +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.receipt-wait-bounds.test.ts @@ -32,6 +32,8 @@ import { resolveDeployReceiptWait, } from '../receipt-identity'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; + const ADDRESSES = { identityRegistry: '0x1111111111111111111111111111111111111111', identityFactory: '0x2222222222222222222222222222222222222222', @@ -46,6 +48,7 @@ function construct(deployReceiptWait?: { const options: CreateIRSOptions = { signAndBroadcast: vi.fn(), addresses: { ...ADDRESSES }, + operatorManagementKey: OPERATOR, deployReceiptWait, }; diff --git a/packages/adapter-evm-core/src/irs/__tests__/irs.writes.test.ts b/packages/adapter-evm-core/src/irs/__tests__/irs.writes.test.ts index cd6d462..b8f7a3d 100644 --- a/packages/adapter-evm-core/src/irs/__tests__/irs.writes.test.ts +++ b/packages/adapter-evm-core/src/irs/__tests__/irs.writes.test.ts @@ -45,6 +45,7 @@ const ADDRESSES = { } as const; const HOLDER = '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; const ONCHAINID = '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'; const ISSUER = '0xcCcCccCcCcCccCcccCccCccCccCccCccCccCccccC'; const ZERO = '0x0000000000000000000000000000000000000000'; @@ -79,7 +80,11 @@ function makeCapability(): { signAndBroadcast: ReturnType; } { const signAndBroadcast = vi.fn().mockResolvedValue({ txHash: '0xtx' }); - const options: CreateIRSOptions = { signAndBroadcast, addresses: { ...ADDRESSES } }; + const options: CreateIRSOptions = { + signAndBroadcast, + addresses: { ...ADDRESSES }, + operatorManagementKey: OPERATOR, + }; const capability = createIRS( { id: 'evm-testnet', @@ -103,22 +108,27 @@ describe('IRS writes', () => { afterEach(() => vi.restoreAllMocks()); describe('deployOnchainId', () => { - it('submits createIdentity and resolves the deployed ONCHAINID from the receipt', async () => { + it('submits createIdentityWithManagementKeys and resolves the deployed ONCHAINID from the receipt', async () => { mockWaitForTransactionReceipt.mockResolvedValueOnce(walletLinkedReceipt()); + mockReadContract.mockResolvedValueOnce(true); // operator MANAGEMENT probe const { capability, signAndBroadcast } = makeCapability(); const result = await capability.deployOnchainId({ holder: HOLDER }, EXEC_CONFIG); expect(result).toEqual({ id: TX_HASH, onchainId: ONCHAINID }); const action = signAndBroadcast.mock.calls[0][0]; - expect(action.functionName).toBe('createIdentity'); + expect(action.functionName).toBe('createIdentityWithManagementKeys'); expect(action.address.toLowerCase()).toBe(ADDRESSES.identityFactory); expect(action.args[0]).toBe(HOLDER); // The deploy path must WAIT for confirmation, never point-in-time check. expect(mockWaitForTransactionReceipt).toHaveBeenCalledOnce(); expect(mockWaitForTransactionReceipt.mock.calls[0]?.[0]).toMatchObject({ hash: TX_HASH }); expect(mockGetTransactionReceipt).not.toHaveBeenCalled(); - expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockReadContract).toHaveBeenCalledOnce(); + expect(mockReadContract.mock.calls[0]?.[0]).toMatchObject({ + functionName: 'keyHasPurpose', + address: ONCHAINID, + }); }); }); diff --git a/packages/adapter-evm-core/src/irs/abis.ts b/packages/adapter-evm-core/src/irs/abis.ts index c25e908..58cbed2 100644 --- a/packages/adapter-evm-core/src/irs/abis.ts +++ b/packages/adapter-evm-core/src/irs/abis.ts @@ -164,6 +164,53 @@ export const CREATE_IDENTITY_ABI: Abi = [ }, ] as const; +/** + * `createIdentityWithManagementKeys(address _wallet, string _salt, bytes32[] _managementKeys) → address` + * — deploys an ONCHAINID whose MANAGEMENT keys are the listed hashes (holder wallet excluded). + */ +export const CREATE_IDENTITY_WITH_MANAGEMENT_KEYS_ABI: Abi = [ + { + type: 'function', + name: 'createIdentityWithManagementKeys', + inputs: [ + { name: '_wallet', type: 'address' }, + { name: '_salt', type: 'string' }, + { name: '_managementKeys', type: 'bytes32[]' }, + ], + outputs: [{ name: '', type: 'address' }], + stateMutability: 'nonpayable', + }, +] as const; + +/** `addKey(bytes32 _key, uint256 _purpose, uint256 _type) → bool` — ERC-734 key registration. */ +export const ADD_KEY_ABI: Abi = [ + { + type: 'function', + name: 'addKey', + inputs: [ + { name: '_key', type: 'bytes32' }, + { name: '_purpose', type: 'uint256' }, + { name: '_type', type: 'uint256' }, + ], + outputs: [{ name: 'success', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const; + +/** `keyHasPurpose(bytes32 _key, uint256 _purpose) → bool` — ERC-734 key probe. */ +export const KEY_HAS_PURPOSE_ABI: Abi = [ + { + type: 'function', + name: 'keyHasPurpose', + inputs: [ + { name: '_key', type: 'bytes32' }, + { name: '_purpose', type: 'uint256' }, + ], + outputs: [{ name: 'result', type: 'bool' }], + stateMutability: 'view', + }, +] as const; + /** IdFactory events used to resolve a freshly deployed ONCHAINID from a receipt. */ export const ID_FACTORY_EVENTS_ABI: Abi = [ { diff --git a/packages/adapter-evm-core/src/irs/actions.ts b/packages/adapter-evm-core/src/irs/actions.ts index d13f1b1..0cdb5d9 100644 --- a/packages/adapter-evm-core/src/irs/actions.ts +++ b/packages/adapter-evm-core/src/irs/actions.ts @@ -15,22 +15,51 @@ import type { OnboardingClaim } from '@openzeppelin/ui-types'; import type { WriteContractParameters } from '../types'; import { ADD_CLAIM_ABI, + ADD_KEY_ABI, ADD_TRUSTED_ISSUER_ABI, - CREATE_IDENTITY_ABI, + CREATE_IDENTITY_WITH_MANAGEMENT_KEYS_ABI, REGISTER_IDENTITY_ABI, } from './abis'; +import { + addressToIdentityKeyHash, + IDENTITY_KEY_PURPOSE_MANAGEMENT, + IDENTITY_KEY_TYPE_ECDSA, +} from './identity-keys'; -/** Assembles `createIdentity(address _wallet, string _salt)` on the identity factory. */ +/** + * Assembles `createIdentityWithManagementKeys` on the identity factory. + * + * Grants MANAGEMENT to `operatorManagementKey` only; the holder is linked as a wallet but + * cannot manage the identity until {@link assembleGrantHolderManagementKeyAction} runs. + */ export function assembleDeployOnchainIdAction( factoryAddress: string, holder: string, - salt: string + salt: string, + operatorManagementKey: string ): WriteContractParameters { return { address: factoryAddress as Hex, - abi: CREATE_IDENTITY_ABI, - functionName: 'createIdentity', - args: [holder as Hex, salt], + abi: CREATE_IDENTITY_WITH_MANAGEMENT_KEYS_ABI, + functionName: 'createIdentityWithManagementKeys', + args: [holder as Hex, salt, [addressToIdentityKeyHash(operatorManagementKey)]], + }; +} + +/** Assembles `addKey(holderKey, MANAGEMENT, ECDSA)` on an ONCHAINID identity. */ +export function assembleGrantHolderManagementKeyAction( + onchainId: string, + holder: string +): WriteContractParameters { + return { + address: onchainId as Hex, + abi: ADD_KEY_ABI, + functionName: 'addKey', + args: [ + addressToIdentityKeyHash(holder), + BigInt(IDENTITY_KEY_PURPOSE_MANAGEMENT), + BigInt(IDENTITY_KEY_TYPE_ECDSA), + ], }; } diff --git a/packages/adapter-evm-core/src/irs/identity-keys.ts b/packages/adapter-evm-core/src/irs/identity-keys.ts new file mode 100644 index 0000000..915ed0b --- /dev/null +++ b/packages/adapter-evm-core/src/irs/identity-keys.ts @@ -0,0 +1,40 @@ +/** + * ONCHAINID ERC-734 key helpers (pinned to `@onchain-id/solidity@2.2.1`). + * + * @module irs/identity-keys + */ + +import { encodeAbiParameters, keccak256, type Hex } from 'viem'; + +import { createEvmPublicClient } from '../utils/public-client'; +import { KEY_HAS_PURPOSE_ABI } from './abis'; + +/** ERC-734 purpose: MANAGEMENT key (can manage the identity, including `addKey`). */ +export const IDENTITY_KEY_PURPOSE_MANAGEMENT = 1; + +/** ERC-734 key type: ECDSA (standard for Ethereum addresses). */ +export const IDENTITY_KEY_TYPE_ECDSA = 1; + +/** + * The bytes32 key hash IdFactory / Identity use for an Ethereum address: + * `keccak256(abi.encode(address))`. + */ +export function addressToIdentityKeyHash(address: string): Hex { + return keccak256(encodeAbiParameters([{ type: 'address' }], [address as Hex])); +} + +/** Read `keyHasPurpose` on an ONCHAINID identity contract. */ +export async function identityKeyHasPurpose( + rpcUrl: string, + onchainId: string, + address: string, + purpose: number +): Promise { + const client = createEvmPublicClient(rpcUrl); + return (await client.readContract({ + address: onchainId as Hex, + abi: KEY_HAS_PURPOSE_ABI, + functionName: 'keyHasPurpose', + args: [addressToIdentityKeyHash(address), BigInt(purpose)], + })) as boolean; +} diff --git a/packages/adapter-evm-core/src/irs/index.ts b/packages/adapter-evm-core/src/irs/index.ts index 9d83822..0a67738 100644 --- a/packages/adapter-evm-core/src/irs/index.ts +++ b/packages/adapter-evm-core/src/irs/index.ts @@ -11,6 +11,8 @@ export * from './abis'; export * from './actions'; export * from './claim-payload'; +export * from './identity-keys'; +export * from './management-key'; export * from './onchain-reader'; export * from './receipt-identity'; export { createEvmIRSService, EvmIRSService } from './service'; diff --git a/packages/adapter-evm-core/src/irs/management-key.ts b/packages/adapter-evm-core/src/irs/management-key.ts new file mode 100644 index 0000000..30ba2f9 --- /dev/null +++ b/packages/adapter-evm-core/src/irs/management-key.ts @@ -0,0 +1,40 @@ +/** + * Validation for the operator management key configured on IRS construction. + * + * The key MUST be the address that will later call `attachClaim` — never inferred from the + * transaction signer, because the factory `onlyOwner` may be a relayer contract. + * + * @module irs/management-key + */ + +import { isValidEvmAddress } from '../utils/validation'; + +/** Thrown when `operatorManagementKey` is missing or not a valid EVM address. */ +export class InvalidOperatorManagementKeyError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidOperatorManagementKeyError'; + } +} + +/** + * Validate the operator management key at capability construction. + * + * @throws {InvalidOperatorManagementKeyError} when absent or malformed. + */ +export function assertValidOperatorManagementKey( + operatorManagementKey: string | undefined +): asserts operatorManagementKey is string { + if (operatorManagementKey === undefined || operatorManagementKey === '') { + throw new InvalidOperatorManagementKeyError( + 'operatorManagementKey is required: pass the address that will execute attachClaim ' + + '(the saga operator EOA). Do not infer it from the transaction signer — the IdFactory ' + + 'owner may be a relayer contract.' + ); + } + if (!isValidEvmAddress(operatorManagementKey)) { + throw new InvalidOperatorManagementKeyError( + `Invalid operatorManagementKey: '${operatorManagementKey}' is not a valid EVM address.` + ); + } +} diff --git a/packages/adapter-evm-core/src/irs/service.ts b/packages/adapter-evm-core/src/irs/service.ts index bf64ee9..2681dbe 100644 --- a/packages/adapter-evm-core/src/irs/service.ts +++ b/packages/adapter-evm-core/src/irs/service.ts @@ -35,9 +35,11 @@ import { assembleAddTrustedIssuerAction, assembleAttachClaimAction, assembleDeployOnchainIdAction, + assembleGrantHolderManagementKeyAction, assembleRegisterIdentityAction, } from './actions'; import { buildClaimPayload } from './claim-payload'; +import { IDENTITY_KEY_PURPOSE_MANAGEMENT, identityKeyHasPurpose } from './identity-keys'; import { getIdentityFromFactory, getJurisdiction, @@ -64,6 +66,7 @@ export const TRUSTED_ISSUER_NOOP_ID = 'noop:trusted-issuer-already-registered'; export class EvmIRSService { private readonly addresses: EvmIRSAddresses; private readonly trustedIssuer?: string; + private readonly operatorManagementKey: string; /** * Wait bounds, resolved AND VALIDATED at construction so a misconfiguration fails at boot * rather than at the first deploy — where the failure would land on a real holder. @@ -77,6 +80,7 @@ export class EvmIRSService { ) { this.addresses = options.addresses; this.trustedIssuer = options.trustedIssuer; + this.operatorManagementKey = options.operatorManagementKey; this.deployReceiptWait = resolveDeployReceiptWait(options.deployReceiptWait); } @@ -118,6 +122,11 @@ export class EvmIRSService { /** * Deploy a fresh ONCHAINID for `holder` and resolve its address from the confirmed receipt. * + * Uses `createIdentityWithManagementKeys` so the configured {@link operatorManagementKey} + * receives MANAGEMENT and can execute the subsequent saga steps (`attachClaim`, etc.). + * The holder is wallet-linked but does **not** receive MANAGEMENT until + * {@link grantHolderManagementKey} runs — that ordering is deliberate (see that method). + * * Identity resolution parses `WalletLinked` (falling back to `Deployed`) out of the receipt * obtained by **waiting** for confirmation — `waitForTransactionReceipt`, bounded by * `deployReceiptWait`. The wait is the gate: a point-in-time `getTransactionReceipt` merely @@ -139,7 +148,12 @@ export class EvmIRSService { runtimeApiKey?: string ): Promise { const { holder } = input; - const action = assembleDeployOnchainIdAction(this.addresses.identityFactory, holder, holder); + const action = assembleDeployOnchainIdAction( + this.addresses.identityFactory, + holder, + holder, + this.operatorManagementKey + ); const result = await this.execute( 'deployOnchainId', @@ -241,9 +255,66 @@ export class EvmIRSService { ); } + await this.assertIdentityKeyHasPurpose({ + operation: 'deployOnchainId', + onchainId, + address: this.operatorManagementKey, + purpose: IDENTITY_KEY_PURPOSE_MANAGEMENT, + missingPurposeMessage: + `ONCHAINID deployment for ${holder} succeeded (identity ${onchainId}) but ` + + `operatorManagementKey ${this.operatorManagementKey} does not hold MANAGEMENT on the ` + + `identity. The configured key must be the address that will execute attachClaim.`, + rpcFailureMessage: + `ONCHAINID deployment for ${holder} succeeded (identity ${onchainId}, tx ${result.id}) but ` + + `could not verify operatorManagementKey ${this.operatorManagementKey} MANAGEMENT via RPC. ` + + `The identity LIKELY EXISTS — resume the saga using onchainId ${onchainId}.`, + }); + return { ...result, onchainId }; } + /** + * Grant the holder a MANAGEMENT key on their ONCHAINID. + * + * **Saga ordering is load-bearing:** consumers MUST call this after `deployOnchainId` and + * **before** `attachClaim`. If attach-claim or register fails partway through onboarding, the + * holder already holds MANAGEMENT and can rescue their own identity. Running this after + * attach-claim would leave a partial failure with an identity only the operator can touch — + * a fresh orphan trap. Do not reorder for convenience. + */ + async grantHolderManagementKey( + input: { onchainId: string; holder: string }, + executionConfig: ExecutionConfig, + onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void, + runtimeApiKey?: string + ): Promise { + const { onchainId, holder } = input; + const action = assembleGrantHolderManagementKeyAction(onchainId, holder); + + const result = await this.execute( + 'grantHolderManagementKey', + action, + executionConfig, + onStatusChange, + runtimeApiKey + ); + + await this.assertIdentityKeyHasPurpose({ + operation: 'grantHolderManagementKey', + onchainId, + address: holder, + purpose: IDENTITY_KEY_PURPOSE_MANAGEMENT, + missingPurposeMessage: + `grantHolderManagementKey for ${holder} on ${onchainId} was submitted (tx ${result.id}) ` + + `but the holder does not hold MANAGEMENT on the identity.`, + rpcFailureMessage: + `grantHolderManagementKey for ${holder} on ${onchainId} was submitted (tx ${result.id}) but ` + + `could not verify holder MANAGEMENT via RPC. Resume the saga using onchainId ${onchainId}.`, + }); + + return result; + } + async registerTrustedIssuer( input: { issuer: string; topics: string[] }, executionConfig: ExecutionConfig, @@ -340,6 +411,34 @@ export class EvmIRSService { return resolveRpcUrl(this.networkConfig); } + private async assertIdentityKeyHasPurpose(input: { + operation: string; + onchainId: string; + address: string; + purpose: number; + missingPurposeMessage: string; + rpcFailureMessage: string; + }): Promise { + const { operation, onchainId, address, purpose, missingPurposeMessage, rpcFailureMessage } = + input; + + let hasPurpose: boolean; + try { + hasPurpose = await identityKeyHasPurpose(this.rpcUrl(), onchainId, address, purpose); + } catch (error) { + throw new IdentityOperationFailed( + rpcFailureMessage, + operation, + error instanceof Error ? error : new Error(String(error)), + onchainId + ); + } + + if (!hasPurpose) { + throw new IdentityOperationFailed(missingPurposeMessage, operation, undefined, onchainId); + } + } + private execute( operation: string, action: WriteContractParameters, diff --git a/packages/adapter-evm-core/src/irs/types.ts b/packages/adapter-evm-core/src/irs/types.ts index 9c45b8e..e76b326 100644 --- a/packages/adapter-evm-core/src/irs/types.ts +++ b/packages/adapter-evm-core/src/irs/types.ts @@ -51,6 +51,13 @@ export interface EvmIRSServiceOptions { * The capability never holds the issuer signing key — only this address. */ trustedIssuer?: string; + /** + * Address that receives MANAGEMENT on deploy and executes `attachClaim` in the onboarding saga. + * + * Must be explicit — never inferred from the transaction signer, because the IdFactory + * `onlyOwner` caller may be a relayer contract distinct from the operator EOA. + */ + operatorManagementKey: string; /** * Bounds on the `deployOnchainId` confirmation wait (confirmations + timeout). * diff --git a/packages/adapter-evm/test/ri-capabilities-subpath-runtime.test.ts b/packages/adapter-evm/test/ri-capabilities-subpath-runtime.test.ts index cc66c5c..3fe9f72 100644 --- a/packages/adapter-evm/test/ri-capabilities-subpath-runtime.test.ts +++ b/packages/adapter-evm/test/ri-capabilities-subpath-runtime.test.ts @@ -47,6 +47,7 @@ const TOKEN = '0x1111111111111111111111111111111111111111'; const IDENTITY_REGISTRY = '0x2222222222222222222222222222222222222222'; const IDENTITY_FACTORY = '0x3333333333333333333333333333333333333333'; const TRUSTED_ISSUERS_REGISTRY = '0x4444444444444444444444444444444444444444'; +const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142'; const HOLDER = '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa'; const ONCHAIN_ID_ADDR = '0xcccccccccccccccccccccccccccccccccccccccc'; const ISSUER = '0xdddddddddddddddddddddddddddddddddddddddd'; @@ -126,6 +127,7 @@ describe('RI capability sub-paths (server-side runtime)', () => { const signAndBroadcast = createStrategySignAndBroadcast(); const irs = createIRS(TEST_NETWORK_CONFIG, { signAndBroadcast, + operatorManagementKey: OPERATOR, addresses: { identityRegistry: IDENTITY_REGISTRY, identityFactory: IDENTITY_FACTORY,