Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/irs-onboard-dual-management.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,7 +40,13 @@ const SC004_COVERAGE: Record<string, CapabilityCoverage> = {
},
irs: {
reads: ['getOnchainId', 'isVerified', 'getJurisdiction'],
writes: ['deployOnchainId', 'registerTrustedIssuer', 'attachClaim', 'registerIdentity'],
writes: [
'deployOnchainId',
'grantHolderManagementKey',
'registerTrustedIssuer',
'attachClaim',
'registerIdentity',
],
testDirs: ['src/irs/__tests__'],
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-evm-core/src/capabilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
38 changes: 35 additions & 3 deletions packages/adapter-evm-core/src/capabilities/irs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).
Expand All @@ -33,27 +49,43 @@ 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<FactoryIdentityLookup>;
grantHolderManagementKey(
input: { onchainId: string; holder: string },
executionConfig: ExecutionConfig,
onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
runtimeApiKey?: string
): Promise<OperationResult>;
}

/**
* Create the EVM IRS / ONCHAINID capability.
*
* Mirrors {@link createAccessControl}: assembles the service, adapts the injected
* `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);
assertValidAddress('addresses.trustedIssuersRegistry', options.addresses.trustedIssuersRegistry);
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,
}
);
Expand All @@ -64,5 +96,5 @@ export function createIRS(config: NetworkConfig, options: CreateIRSOptions): IRS
'irs',
() => service.dispose(),
'general'
) as unknown as IRSCapability;
) as unknown as EvmIRSCapability;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -35,6 +37,7 @@ function makeCapability(): { capability: IRSCapability } {
identityFactory: FACTORY,
trustedIssuersRegistry: TRUSTED_ISSUERS,
},
operatorManagementKey: OPERATOR,
};
const capability = createIRS(
{
Expand Down
23 changes: 20 additions & 3 deletions packages/adapter-evm-core/src/irs/__tests__/irs.factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -24,6 +24,8 @@ const mockNetworkConfig = {
nativeCurrency: { name: 'Test Ether', symbol: 'TETH', decimals: 18 },
} as const;

const OPERATOR = '0xDD601cb1dDb4471e88C51A5f64A9d54294179142';

function makeOptions(overrides: Partial<CreateIRSOptions> = {}): CreateIRSOptions {
return {
signAndBroadcast: vi.fn().mockResolvedValue({ txHash: '0xtx' }),
Expand All @@ -32,19 +34,21 @@ function makeOptions(overrides: Partial<CreateIRSOptions> = {}): 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');
Expand Down Expand Up @@ -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());

Expand Down
Loading
Loading