From cc6c44e96002e81e73c46a3f73b83fdd6ecd7ffc Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 22 Oct 2026 17:48:58 -0600 Subject: [PATCH 1/2] Support guides-only docs generation for TypeScript packages Co-Authored-By: Claude Opus 5.5 --- .../generate-api-docs-identity-sdk.yml | 140 ++++++++++++++++++ scripts/generate-api-docs.js | 49 ++++++ 2 files changed, 189 insertions(+) create mode 100644 .github/workflows/generate-api-docs-identity-sdk.yml diff --git a/.github/workflows/generate-api-docs-identity-sdk.yml b/.github/workflows/generate-api-docs-identity-sdk.yml new file mode 100644 index 00000000..c517db8e --- /dev/null +++ b/.github/workflows/generate-api-docs-identity-sdk.yml @@ -0,0 +1,140 @@ +# Docs Repository Receiver - Non-Versioned Paths +# Receives trigger from identity-sdk and copies its MDX guides (TypeScript package, no generated API reference) + +name: Generate API Docs - Identity SDK + +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name or commit SHA to generate docs from' + required: true + type: string + default: 'main' + trigger_type: + description: 'Trigger type (tag or commit)' + required: false + type: string + default: 'tag' + +permissions: + contents: write + pull-requests: write + +env: + SOURCE_REPO: 'OpenZeppelin/identity-sdk' + SOURCE_REPO_URL: 'https://github.com/OpenZeppelin/identity-sdk.git' + GUIDES_MDX: 'docs' + OUTPUT_DIR: 'content/identity-sdk' + +jobs: + generate-docs: + runs-on: ubuntu-latest + + steps: + - name: Log trigger information + run: | + echo "🚀 API Documentation Generation Triggered" + echo "đŸ“Ļ Repository: ${{ env.SOURCE_REPO }}" + echo "đŸˇī¸ Tag/Ref: ${{ inputs.tag_name }}" + echo "📋 Trigger type: ${{ inputs.trigger_type }}" + + - name: Checkout docs repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Copy guides + run: | + echo "🔄 Copying guides..." + + node scripts/generate-api-docs.js \ + --repo "${{ env.SOURCE_REPO_URL }}" \ + --branch "${{ inputs.tag_name }}" \ + --guides-only \ + --guides-output "${{ env.OUTPUT_DIR }}" \ + --guides-mdx "${{ env.GUIDES_MDX }}" + + - name: Validate generated documentation + run: | + pnpm run postinstall + pnpm run lint:links + + - name: Create Pull Request for documentation changes + id: create_pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --local user.email "docs-automation@openzeppelin.com" + git config --local user.name "OpenZeppelin Docs Bot" + + if [ -n "$(git status --porcelain)" ]; then + echo "📝 Creating branch and committing documentation changes..." + + BRANCH_NAME="docs/api-update-${{ env.SOURCE_REPO }}-${{ inputs.tag_name }}" + BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/\//-/g' | sed 's/OpenZeppelin-//g') + + git checkout -b "$BRANCH_NAME" + git add content/ + git commit -m "Update API docs for ${{ env.SOURCE_REPO }} ${{ inputs.tag_name }} + + - Repository: ${{ env.SOURCE_REPO }} + - Ref: ${{ inputs.tag_name }} + - Trigger: ${{ inputs.trigger_type }} + - Output: ${{ env.OUTPUT_DIR }} + - Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC') + + Auto-generated via workflow_dispatch" + + git push origin "$BRANCH_NAME" + + gh pr create --title "[CI] Update API docs for ${{ env.SOURCE_REPO }} ${{ inputs.tag_name }}" \ + --body "## API Documentation Update + + This Pull Request updates the API documentation. + + ### Source Information + - **Repository:** ${{ env.SOURCE_REPO }} + - **Reference:** ${{ inputs.tag_name }} + - **Trigger Type:** ${{ inputs.trigger_type }} + - **Output Directory:** ${{ env.OUTPUT_DIR }} + - **Timestamp:** $(date -u '+%Y-%m-%d %H:%M:%S UTC') + + Auto-generated via workflow_dispatch" \ + --base main \ + --head "$BRANCH_NAME" || { + echo "::error::Pull Request creation failed for ${{ env.SOURCE_REPO }} ${{ inputs.tag_name }}" + exit 1 + } + + echo "✅ Pull Request created successfully" + echo "pr-created=true" >> $GITHUB_OUTPUT + else + echo "â„šī¸ No changes detected - documentation is up to date" + echo "pr-created=false" >> $GITHUB_OUTPUT + fi + + - name: Create job summary + run: | + echo "## 📚 API Documentation Generation Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Source Information" >> $GITHUB_STEP_SUMMARY + echo "- **Repository:** ${{ env.SOURCE_REPO }}" >> $GITHUB_STEP_SUMMARY + echo "- **Reference:** ${{ inputs.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Trigger Type:** ${{ inputs.trigger_type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Output" >> $GITHUB_STEP_SUMMARY + echo "- **Directory:** ${{ env.OUTPUT_DIR }}" >> $GITHUB_STEP_SUMMARY + echo "- **Status:** ✅ Complete" >> $GITHUB_STEP_SUMMARY diff --git a/scripts/generate-api-docs.js b/scripts/generate-api-docs.js index 762463f8..35b7394c 100755 --- a/scripts/generate-api-docs.js +++ b/scripts/generate-api-docs.js @@ -25,6 +25,7 @@ function parseArgs() { guidesMdx: null, sourcePackage: "", packageName: null, + guidesOnly: false, }; for (let i = 0; i < args.length; i++) { @@ -76,6 +77,9 @@ function parseArgs() { case "--package-name": options.packageName = args[++i]; break; + case "--guides-only": + options.guidesOnly = true; + break; default: console.error(`Unknown option: ${arg}`); showHelp(); @@ -103,6 +107,7 @@ Options: --guides-mdx Path within repo containing MDX guides to copy into --guides-output (skips .adoc conversion) -s, --source-package Path within repo of the Solidity package to run docgen in (default: repo root) --package-name npm package name used in import paths (default: derived from the repo name) + --guides-only Only copy the --guides-mdx guides into --guides-output (no API docs; --api-output is ignored) --skip-template-inject Skip injecting canonical templates (use source repo's own) -h, --help Show this help message @@ -117,6 +122,9 @@ Examples: # Solidity package in a monorepo, with MDX guides: node generate-api-docs.js --repo https://github.com/OpenZeppelin/openzeppelin-accounts.git --api-output content/accounts/api --source-package packages/contracts --package-name @openzeppelin-accounts/contracts --guides-output content/accounts --guides-mdx docs + # TypeScript package with hand-written MDX guides only: + node generate-api-docs.js --repo https://github.com/OpenZeppelin/identity-sdk.git --branch main --guides-only --guides-output content/identity-sdk --guides-mdx docs + # Non-Solidity repo (copies pre-generated MDX): node generate-api-docs.js --repo https://github.com/OpenZeppelin/cairo-contracts.git --api-output content/contracts-cairo/3.x/api --pre-generated docs/api `); @@ -244,6 +252,11 @@ async function generateApiDocs(options) { sourcePackage, } = options; + if (options.guidesOnly) { + await generateGuidesOnly(options); + return; + } + console.log("🔄 Generating OpenZeppelin API documentation..."); console.log(`đŸ“Ļ Repository: ${contractsRepo}`); console.log(`đŸŒŋ Branch: ${contractsBranch}`); @@ -412,6 +425,42 @@ async function generateApiDocs(options) { } } +// Copy a repo's MDX guides with no API docs (e.g. TypeScript packages). The output directory +// is owned by the guides, so it is cleared first to drop pages removed upstream. +async function generateGuidesOnly(options) { + const { contractsRepo, contractsBranch, tempDir, guidesOutputDir, guidesMdx } = + options; + + if (!guidesOutputDir || !guidesMdx) { + console.error("❌ --guides-only requires --guides-output and --guides-mdx"); + process.exit(1); + } + + console.log("📖 Copying MDX guides..."); + console.log(`đŸ“Ļ Repository: ${contractsRepo}`); + console.log(`đŸŒŋ Branch: ${contractsBranch}`); + console.log(`📂 Guides Output: ${guidesOutputDir}`); + + try { + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(guidesOutputDir, { recursive: true, force: true }); + + execFileSync( + "git", + ["clone", "--depth", "1", "--branch", contractsBranch, contractsRepo, tempDir], + { stdio: "inherit" }, + ); + + await copyGuides(tempDir, guidesMdx, guidesOutputDir); + + await fs.rm(tempDir, { recursive: true, force: true }); + console.log("🎉 Guides copy complete!"); + } catch (error) { + console.error("❌ Error copying guides:", error.message); + process.exit(1); + } +} + async function convertGuides(tempDir, guidesOutputDir, apiOutputDir) { const guidesPath = path.join(tempDir, "docs", "modules", "ROOT", "pages"); From f25f334ac0d77a1a4d7799370fed5bf7a9209c94 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 22 Oct 2026 17:48:59 -0600 Subject: [PATCH 2/2] Add Identity SDK docs Co-Authored-By: Claude Opus 5.5 --- content/identity-sdk/abi.mdx | 68 ++++ content/identity-sdk/concepts.mdx | 186 +++++++++++ content/identity-sdk/gas.mdx | 115 +++++++ .../guides/account-abstraction.mdx | 171 ++++++++++ content/identity-sdk/guides/claims.mdx | 314 ++++++++++++++++++ .../identity-sdk/guides/deploy-identity.mdx | 162 +++++++++ content/identity-sdk/guides/execution.mdx | 124 +++++++ content/identity-sdk/guides/keys.mdx | 211 ++++++++++++ content/identity-sdk/guides/modules.mdx | 144 ++++++++ content/identity-sdk/guides/passkeys.mdx | 110 ++++++ .../identity-sdk/guides/wallet-linking.mdx | 197 +++++++++++ content/identity-sdk/index.mdx | 117 +++++++ src/navigation/ethereum-evm.json | 72 ++++ 13 files changed, 1991 insertions(+) create mode 100644 content/identity-sdk/abi.mdx create mode 100644 content/identity-sdk/concepts.mdx create mode 100644 content/identity-sdk/gas.mdx create mode 100644 content/identity-sdk/guides/account-abstraction.mdx create mode 100644 content/identity-sdk/guides/claims.mdx create mode 100644 content/identity-sdk/guides/deploy-identity.mdx create mode 100644 content/identity-sdk/guides/execution.mdx create mode 100644 content/identity-sdk/guides/keys.mdx create mode 100644 content/identity-sdk/guides/modules.mdx create mode 100644 content/identity-sdk/guides/passkeys.mdx create mode 100644 content/identity-sdk/guides/wallet-linking.mdx create mode 100644 content/identity-sdk/index.mdx diff --git a/content/identity-sdk/abi.mdx b/content/identity-sdk/abi.mdx new file mode 100644 index 00000000..d6f65c9b --- /dev/null +++ b/content/identity-sdk/abi.mdx @@ -0,0 +1,68 @@ +--- +title: ABIs +description: The vendored ONCHAINID ABIs, their pin, and how to re-sync them +--- + +The SDK vendors the ABIs of the ONCHAINID contracts it talks to under `src/abi/`. They are exported from the package root and typed with viem's `parseAbi`, so `readContract`, `encodeFunctionData` and friends infer argument and return types from them. + +Use them directly for anything the SDK does not wrap (for example, the factory's policy views, reputation reads, or decoding custom errors). + +## Pin + +Every file is generated from **T-REX-Network/ONCHAINID at commit `2180c919`**. Each file's header names the commit and the source contract: + +```ts +// Generated from T-REX-Network/ONCHAINID@2180c919: contracts/interface/IIdentity.sol +// Do not hand-edit the ABI: rerun `scripts/sync-abi.mjs` against the new pin. +``` + +Integrate against contracts deployed from the same ONCHAINID version. A deployment from a different commit may have different selectors or struct layouts. + +## Exports + +| Export | Source | Address to call | +| ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `ierc734Abi` | `contracts/interface/IERC734.sol` | Identity | +| `ierc735Abi` | `contracts/interface/IERC735.sol` | Identity | +| `iidentityAbi` | `contracts/interface/IIdentity.sol` | Identity: keys, claims, `isClaimValid`, `getIdentityType` | +| `ikeyExecutorAbi` | `contracts/interface/IKeyExecutor.sol` | Identity: ERC-734 `execute` / `approve` (served by `KeyApprovalModule`) | +| `iclaimIssuerAbi` | `contracts/interface/IClaimIssuer.sol` | Issuer identity: `revokeClaimByDigest`, `isDigestRevoked`, `addClaimTo` | +| `erc734ValidatorAbi` | `contracts/modules/validators/ERC734Validator.sol` | Registry module: explicit-`account` and paginated reads, `getKeyData` | +| `iidentityFactoryAbi` | `contracts/factory/IIdentityFactory.sol` | `IdentityFactory` | +| `ireputationRegistryAbi` | `contracts/reputation/IReputationRegistry.sol` | `ReputationRegistry`: `reputationOf`, `entryOf`, `claimAddThreshold` | +| `iidentityUtilitiesAbi` | `contracts/interface/IIdentityUtilities.sol` | `IdentityUtilities`: topic schemas | +| `identityAccountAbi` | `contracts/Identity.sol` (curated subset) | Identity: `registryModule`, `identityFactory`, `isManagementTarget`, `addKeyWithData`, paginated key reads, modules | +| `keyApprovalModuleAbi` | `contracts/modules/executors/KeyApprovalModule.sol` | `KeyApprovalModule`: `getExecutionData(account, executionId)`, the module's events and errors | + +`identityAccountAbi` is curated: it lists only the account-level functions and events that the interfaces above do not cover. The other files contain the full interface. + +Example, reading a reputation score (no SDK wrapper exists): + +```ts +import { readContract } from 'viem/actions'; +import { ireputationRegistryAbi } from '@openzeppelin/identity-sdk'; + +const score = await readContract(client, { + address: reputationRegistry, + abi: ireputationRegistryAbi, + functionName: 'reputationOf', + args: [identity], +}); +``` + +## Re-syncing to a new pin + +The ABIs are generated, not hand-written. To move to a new ONCHAINID commit: + +1. Check out T-REX-Network/ONCHAINID at the new commit and run `forge build`. +2. From this repository, run: + + ```bash + ONCHAINID_DIR= npm run sync-abi + ``` + + `sync-abi` runs `scripts/sync-abi.mjs` and then Prettier on `src/abi`. The script reads each artifact from `/out/`, writes the new commit into every header, sorts entries (events, then errors, then functions, alphabetically) so diffs stay readable, and keeps each file's hand-written doc comment. + +3. Run `npm run compile && npm test` to catch shape changes downstream, then review the doc comments in `src/abi/` against what changed. + +To add a new ABI, add an entry to the `ABIS` list in `scripts/sync-abi.mjs` (output file, export name, artifact name, source path, and an optional `pick` list for curated subsets), then export it from `src/abi/index.ts`. diff --git a/content/identity-sdk/concepts.mdx b/content/identity-sdk/concepts.mdx new file mode 100644 index 00000000..f22ce140 --- /dev/null +++ b/content/identity-sdk/concepts.mdx @@ -0,0 +1,186 @@ +--- +title: Concepts +description: Identities, keys, purposes, claims, wallet linking and the factory, as the SDK models them +--- + +This page describes the ONCHAINID model as the SDK sees it. The guides assume these terms. + +## Identities are smart accounts + +An ONCHAINID identity is a contract deployed by an `IdentityFactory` behind a beacon proxy. The `Identity` implementation extends OpenZeppelin's `AccountERC7579Upgradeable`, so every identity is an ERC-7579 modular account and an ERC-4337 account at the same time. + +Two modules matter for integrators: + +- `ERC734Validator`, the **registry module**. It holds both the ERC-734 key registry and the ERC-735 claim registry, and it answers `validateUserOp` for user operations. Its storage is keyed by the identity address. +- `KeyApprovalModule`, which serves the ERC-734 execution queue (`execute` / `approve`). See [Execution queue](/identity-sdk/guides/execution). + +The identity routes ERC-734 and ERC-735 selectors (such as `getKey`, `addClaim`, `getClaim`) to these modules through its ERC-7579 fallback. That is why almost every SDK read and encoder targets the identity address itself, not the module. + +Each identity has two immutables set at construction, readable with `identityAccountAbi`: + +- `identityFactory()`: the factory that deployed it; +- `registryModule()`: its `ERC734Validator`. + +There are two ways to make an identity act: + +- **User operations.** Connect with `toIdentity` and send through any ERC-4337 bundler. The identity is `msg.sender` at every target. See [Account abstraction](/identity-sdk/guides/account-abstraction). +- **Direct transactions.** A wallet holding a suitable key on the identity sends a transaction to the identity (for example, `addClaim` from a key with `CLAIM_ADDER`). The identity sees the wallet as the caller and checks its key. Calls that must come _from_ the identity, such as linking a wallet at the factory, need the user operation path or the identity's ERC-734 `execute` ([Execution queue](/identity-sdk/guides/execution)). + +Some writes only work as direct transactions. They check the key of the address that called the identity, and in the identity's own user operation that caller is the identity itself, which holds no key. So holder-side `addClaim` / `removeClaim`, issuer-side `revokeClaimByDigest` / `addClaimTo`, and ERC-734 `execute` / `approve` must be direct transactions from a key holder. Key and module management also accept a call from the identity itself, so they work on both paths. Wallet linking must come from the identity itself: a user operation, or ERC-734 `execute`. + +All encoders return a `Call` (`{ to: Address; data: Hex }`), which fits both paths: pass it to a wallet client's `sendTransaction`, or put it in `sendUserOperation({ calls })`. + +## Keys + +A key is identified by its **signer data**: ERC-7913 signer bytes. + +- For an EOA (or an ERC-1271 contract) the signer data is its 20-byte address. +- For any other signer (passkey, RSA, other ERC-7913 verifiers) it is `verifier (20 bytes) || key (rest)`. `encodeVerifierSigner` builds it; see [Passkeys](/identity-sdk/guides/passkeys). + +The registry accepts signer data between 20 and 1024 bytes. It stores every key under: + +```ts +keyHash = keccak256(signerData); +``` + +The registry never trusts a caller-supplied `keyHash`, so the SDK always derives it locally (`toKeyParam`, `prepareAddKey`). Reads such as `getKeysByPurpose` return these hashes, and `getKeyData` maps a hash back to its signer data. + +Each key has a **key type** (`KeyTypes`) and one or more **purposes** (`KeyPurposes`). A key can hold several purposes at once. + +| `KeyPurposes` | Value | Meaning | +| -------------- | ----- | --------------------------------------------------------------------------------- | +| `MANAGEMENT` | `1n` | Manage the identity: keys, modules. Root of trust. Satisfies every purpose check. | +| `ACTION` | `2n` | Act on behalf of the identity (signing, logins, transactions to other contracts). | +| `CLAIM_SIGNER` | `3n` | Sign claims about other identities (on an issuer identity). | +| `ENCRYPTION` | `4n` | Off-chain encryption usage. | +| `CLAIM_ADDER` | `5n` | Add claims to the identity, but not remove them. | +| `PROPOSER` | `6n` | Queue executions through `KeyApprovalModule`. Cannot auto-run or self-approve. | + +| `KeyTypes` | Value | Signer data | +| ---------------- | ----- | --------------------------------------------------------------- | +| `ECDSA` | `1n` | 20-byte address (secp256k1) | +| `RSA` | `2n` | RSA modulus and exponent per ERC-7913 | +| `WEBAUTHN` | `3n` | P-256 key verified through an ERC-7913 verifier | +| `MODULE` | `4n` | ERC-7579 executor module; gates `executeFromExecutor` | +| `ACCESS_MANAGER` | `5n` | OpenZeppelin AccessManager governing the identity through roles | + +Executor modules act through `executeFromExecutor` only while they hold a `MODULE` key; see [Modules](/identity-sdk/guides/modules). + +`keyHasPurpose` returns `true` for a key that holds `MANAGEMENT`, whatever purpose is asked, because the on-chain check treats MANAGEMENT as universal. + +A key may also carry **client data**: non-cryptographic metadata such as a WebAuthn credential id. It is stored per key and defaults to `0x`. + +## Claims + +A claim is a statement one identity (the **issuer**) makes about another (the **subject**). The subject stores it in its claim registry. + +| Field | Meaning | +| ----------- | ----------------------------------------------------------------------- | +| `issuer` | The issuer identity address (not the signing key) | +| `topic` | ERC-735 topic, a `bigint` (for example a KYC or accreditation topic id) | +| `scheme` | ERC-735 signature scheme, a `bigint` | +| `signature` | `abi.encode(bytes signer, bytes signature)` (see below) | +| `data` | `ClaimData` | +| `uri` | Pointer to off-chain metadata; may be empty | + +ONCHAINID replaces ERC-735's raw `data` bytes with a struct: + +```ts +type ClaimData = { + issuedAt: bigint; // must be > 0; the claim is not valid before it + validUntil: bigint; // 0n means no expiry + metadataHash: Hex; // binds scheme and uri to the signature + payload: Hex; // topic-specific contents +}; +``` + +`metadataHash` is the EIP-712 struct hash of `Metadata(uint256 scheme, string uri)`. `getMetadataHash({ scheme, uri })` computes it. `prepareAddClaim` derives it for you, which is why it takes `ClaimContents` (`ClaimData` without `metadataHash`). + +Issuers sign the claim as EIP-712 typed data (`CLAIM_TYPES`) under the **issuer identity's** EIP-712 domain. The signing key must hold `CLAIM_SIGNER` on the issuer. The stored signature is not the raw signature: it is `abi.encode(bytes signer, bytes signature)`, where `signer` is the key's signer data. The registry decodes `signer` to find the key, then checks the raw signature. + +An identity holds at most one claim per `(issuer, topic)`. The claim id is `keccak256(abi.encode(issuer, topic))` (`computeClaimId`). Adding a claim under an existing pair overwrites it, which is how renewals work. + +Issuers can revoke a claim by its EIP-712 digest (`prepareRevokeClaimByDigest`, read back with `isDigestRevoked`), and can store a claim on its subject themselves with `prepareAddClaimTo`. Holders can remove a claim from their own registry with `removeClaim`. + +See [Claims](/identity-sdk/guides/claims). + +## Wallet linking + +The `IdentityFactory` keeps a registry from wallets to identities. A wallet is not stored as a bare address but as an **ERC-7930 interoperable address** (a byte envelope that names the chain and the address). Build one for an EVM account with: + +```ts +formatEvmV1({ chainId, address }); // 0x0001 || 0x0000 || len(chainRef) || chainRef || 0x14 || address +``` + +The factory keys wallets by a canonical form of the envelope, so differently padded encodings of the same wallet resolve to the same entry and share one nonce. + +Each wallet has an `AccountStatus`: + +| `AccountStatus` | Value | Meaning | +| --------------- | ----- | -------------------------------------------------------------- | +| `None` | `0n` | Never linked | +| `Active` | `1n` | Linked to an identity | +| `Revoked` | `2n` | Was linked; **terminal**, the wallet can never be linked again | + +The wallet that deploys an identity (or the `account` named in `createIdentityFor`) is linked automatically. Further wallets are linked by the identity itself, with the wallet's consent as an EIP-712 `LinkAccount` signature under the factory's domain. A factory identity's own address resolves to itself and reports `Active`. + +Wallets on other chains propose a link through a trusted ERC-7786 gateway; the proposal waits at the factory until the identity accepts or declines it. + +See [Wallet linking](/identity-sdk/guides/wallet-linking). + +## The factory + +The `IdentityFactory`: + +- deploys identities with CREATE3 behind a beacon proxy, so addresses do not depend on the implementation bytecode; +- assigns each identity an **identity type** (`IdentityTypes`: `ASSET`, `INDIVIDUAL`, `CORPORATE`, `IOT`, `CLAIM_ISSUER`, `SMART_CONTRACT`, `PUBLIC_AUTHORITY`, `AI_AGENT`); +- applies a per-type **policy**: whether the type is self-deployable, which AccessManager role may deploy it for someone else, and whether it binds a single wallet; +- installs the modules registered for the type; +- holds the wallet linking registry. + +The deploy address depends on the factory, the identity type, the caller's salt string, the linked wallet and the initial keys: + +```text +deploySalt = keccak256(abi.encode(identityType, salt, account, keccak256(abi.encode(keys)))) +address = CREATE3(factory, deploySalt) +``` + +`computeDeploySalt` and `computeIdentityAddress` reproduce this off-chain. Modules are not part of the salt. + +`isFactoryIdentity` and `identityTypeOf` tell whether an address was deployed by a given factory and with which type. `getIdentityType` reads the type from the identity itself. + +See [Deploy an identity](/identity-sdk/guides/deploy-identity). + +## Which purpose a call needs + +When an identity executes calls (for example, the calls of a user operation), the signer needs: + +- **MANAGEMENT** if any call targets the identity itself, its factory, or its registry module; +- **ACTION** otherwise. + +The identity publishes this rule as a view, and the SDK asks it rather than reimplementing it, since the factory and registry are per-identity and the rule can change with the beacon implementation: + +```ts +import { isManagementTarget, purposeForTargets } from '@openzeppelin/identity-sdk'; + +// One target. +const needsManagement = await isManagementTarget(client, { identity, target }); + +// A whole batch: MANAGEMENT if any target is management-grade, ACTION otherwise. +const purpose = await purposeForTargets(client, { identity, targets: calls.map(c => c.to) }); +``` + +The strictest purpose wins: one management-grade target makes the whole operation require MANAGEMENT. `purposeForTargets` asks about every distinct target in one multicall, so it needs Multicall3 at its canonical address (deployed on all major public chains; predeploy it on a local chain). + +This means that key and module writes (which target the identity) and wallet linking (which targets the factory) need a MANAGEMENT key when sent as user operations. + +The rule is the user operation's. Direct transactions are checked by the function they call (for example, `addClaim` accepts `CLAIM_ADDER`), and the ERC-734 execution queue has its own rules; see [Execution queue](/identity-sdk/guides/execution). + +To check whether the connected signer qualifies before sending: + +```ts +import { keccak256 } from 'viem'; +import { keyHasPurpose } from '@openzeppelin/identity-sdk'; + +const allowed = await keyHasPurpose(client, { identity, key: keccak256(account.signerData), purpose }); +``` diff --git a/content/identity-sdk/gas.mdx b/content/identity-sdk/gas.mdx new file mode 100644 index 00000000..97421b7f --- /dev/null +++ b/content/identity-sdk/gas.mdx @@ -0,0 +1,115 @@ +--- +title: Gas +description: Why gas estimates for calls an identity forwards can fall short, and how to add headroom +--- + +Gas estimates for calls that an identity forwards can be just short of what the inner call needs. The symptom is confusing: the transaction succeeds, the intended effect does not happen, and re-simulating the same call later passes. This page explains why and how to size gas limits so it does not happen. + +## When it applies + +Any call that reaches its target **through** the identity: + +- a direct transaction to the identity's ERC-734 `execute(to, value, data)`, which forwards the call to `to`; +- a user operation, where the EntryPoint calls the identity and the identity calls each target; +- calls the identity makes to its own registry module, such as `addClaim`, which then verifies the issuer's signature in further nested calls. + +## Why it happens + +EIP-150 caps the gas a contract can pass to a sub-call at 63/64 of the gas it has left. Each level of nesting withholds 1/64. + +Gas estimation (`eth_estimateGas`) searches for the smallest gas limit with which the **outer** transaction does not revert. The identity catches a failed inner call and continues (ERC-734 `execute` emits `ExecutionFailed`; a user operation is marked failed inside a successful bundle). So a limit that starves the inner call still counts as a success for the outer transaction, and an estimate that is exactly enough for the whole transaction can leave the inner call short once 1/64 is withheld at each hop. + +The result: + +- the outer transaction (or the bundle) is mined with status `success`; +- the inner execution ran out of gas and failed; +- a later `eth_call` simulation of the same call passes, because it runs with a large gas allowance and does not reproduce the 63/64 split at the original limit. + +## Observed on Base Sepolia + +An `execute(addClaim)` transaction was estimated at **1,117,948** gas and used **1,107,987**. The transaction succeeded; the inner `addClaim` did not complete. Re-running the same call as an `eth_call` succeeded. + +## Guidance: add headroom + +The SDK has no gas helper. Every write is a viem `Call`, and the gas knobs are viem's. + +The T-REX integration adopted a **2x margin** over the estimate. The extra limit only affects the balance that must be available while the transaction is in flight: unused gas is refunded on a direct transaction. On the user operation path, the account (or paymaster) must cover the higher limit as prefund; see the note on EntryPoint penalties below. + +### Direct transactions + +Estimate, then pass an explicit `gas` above the estimate: + +```ts +import { prepareExecute } from '@openzeppelin/identity-sdk'; + +// Route a call through the identity's ERC-734 execute. +const request = { account: wallet, ...prepareExecute({ identity, to: target, data: calldata }) }; + +const estimate = await publicClient.estimateGas(request); +const hash = await walletClient.sendTransaction({ ...request, gas: estimate * 2n }); +``` + +The same applies to any `Call` from the SDK sent with `sendTransaction`, including `prepareCreateIdentity`. + +### User operations + +Estimate with the bundler, then pass `callGasLimit` (and, if validation also runs short, `verificationGasLimit`) to `sendUserOperation`: + +```ts +const calls = [prepareAddClaim({ identity, topic, scheme, issuer, signature, data, uri })]; + +const estimate = await bundler.estimateUserOperationGas({ calls }); + +const hash = await bundler.sendUserOperation({ + calls, + callGasLimit: estimate.callGasLimit * 2n, + verificationGasLimit: estimate.verificationGasLimit * 2n, + preVerificationGas: estimate.preVerificationGas, +}); +``` + +viem keeps any limit you pass and estimates the ones you leave out, so passing only `callGasLimit` also works. + +The `identityActions()` methods accept the same overrides: + +```ts +await bundler.addKey({ + identity, + signerData, + purpose: KeyPurposes.ACTION, + keyType: KeyTypes.ECDSA, + callGasLimit: estimate.callGasLimit * 2n, +}); +``` + +To apply a rule to every operation, pass `userOperation.estimateGas` to `toIdentity`. Fields it returns are kept, and the bundler estimates the rest: + +```ts +const account = await toIdentity(client, { + address: identity, + signer, + userOperation: { estimateGas: async () => ({ verificationGasLimit: 200_000n }) }, +}); +``` + +Validation can be under-estimated too. `toIdentity` gives the bundler a stub signature sized like an ECDSA signature, so for keys with longer signatures (passkeys, RSA) the bundler under-estimates `verificationGasLimit` and `preVerificationGas`. Set both with `userOperation.estimateGas`; see [Passkeys](/identity-sdk/guides/passkeys#sign-user-operations-with-a-passkey). + +Even for ECDSA keys, with the alto version used in the SDK's live tests, every operation after an account's first was estimated with too little `verificationGasLimit`, and the bundler dropped it with `AA26 over verificationGasLimit` after accepting it. The live tests use a floor of 200,000, as the accounts SDK's own tests do. + +EntryPoint v0.7 and later charge a penalty on a large unused share of the execution gas limit (10% of the unused amount above a small threshold). A 2x `callGasLimit` is therefore not entirely free on the user operation path; it is still far cheaper than a failed operation. + +## Detecting the failure + +Do not rely on the transaction status alone: + +- **Direct `execute`**: check the receipt logs for `ExecutionFailed` (in `ikeyExecutorAbi`) rather than `Executed`, or read the execution back with `getExecution` and check `succeeded` (see [Execution queue](/identity-sdk/guides/execution)). +- **User operations**: check `success` on the receipt from `waitForUserOperationReceipt`, not the status of the bundle transaction. + +```ts +import { parseEventLogs } from 'viem'; +import { ikeyExecutorAbi } from '@openzeppelin/identity-sdk'; + +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +const failed = parseEventLogs({ abi: ikeyExecutorAbi, eventName: 'ExecutionFailed', logs: receipt.logs }); +if (failed.length > 0) throw new Error('inner execution failed; retry with more gas'); +``` diff --git a/content/identity-sdk/guides/account-abstraction.mdx b/content/identity-sdk/guides/account-abstraction.mdx new file mode 100644 index 00000000..a47bdcda --- /dev/null +++ b/content/identity-sdk/guides/account-abstraction.mdx @@ -0,0 +1,171 @@ +--- +title: Account Abstraction +description: Drive an identity as an ERC-4337 account with toIdentity and identityActions +--- + +Every ONCHAINID identity is an ERC-4337 account. `toIdentity` turns a deployed identity into a viem `SmartAccount`, which any viem bundler client (or a compatible library) can use to send user operations. + +`toIdentity` requires `@openzeppelin-accounts/sdk` (see the [README](/identity-sdk#installation)). + +## Connect with `toIdentity` + +```ts +import { createPublicClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { baseSepolia } from 'viem/chains'; +import { toIdentity } from '@openzeppelin/identity-sdk'; + +const client = createPublicClient({ chain: baseSepolia, transport: http() }); +const signer = privateKeyToAccount(process.env.SIGNER_KEY as `0x${string}`); + +const account = await toIdentity(client, { + address: identity, // a deployed identity + signer, // must hold a key on the identity +}); + +account.address; // the identity +account.signerData; // the ERC-7913 signer bytes the key is registered under +``` + +Parameters: + +- `client`: a viem `Client` **built with a `chain`**. `toIdentity` throws without one: user operations are signed as EIP-712 typed data, and a missing chain would silently produce a chain-1 signature that the EntryPoint rejects (`AA24 signature error`). +- `address`: the identity. `toIdentity` reads its `identityFactory()` and checks `isFactoryIdentity` on that factory, so a wrong address or wrong chain fails here rather than at the bundler. +- `signer`: a viem `LocalAccount`, a viem `WebAuthnAccount`, or an accounts SDK `Signer` (for passkeys, RSA or other ERC-7913 signers). +- `signerData` (optional): the signer bytes the key is registered under. Defaults to the signer's address for a `LocalAccount`, or its `signerBytes` for an accounts SDK signer. A `WebAuthnAccount` has neither, so pass it explicitly (see [Passkeys](/identity-sdk/guides/passkeys)). Pass it also when the key was registered under different bytes. It must be 20 to 1024 bytes. +- `userOperation` (optional): forwarded to the viem `SmartAccount`. Set `userOperation.estimateGas` to adjust the bundler's gas estimates, for example a floor on `verificationGasLimit`. Fields it returns are kept, and the bundler estimates the rest. See [Gas](/identity-sdk/gas#user-operations). + +It returns an `IdentityAccount`: a viem `SmartAccount` plus `signerData`. `keccak256(account.signerData)` is the signer's `keyHash`, which you can use to check its purposes before sending (see [Concepts](/identity-sdk/concepts#which-purpose-a-call-needs)). + +Internally, `toIdentity` encodes the registry module address into the user operation nonce key, which is how an ERC-7579 account picks its validator, and reads nonces from viem's EntryPoint v0.9 address (`entryPoint09Address`). Use a bundler that supports that EntryPoint. + +### Connect-only + +`toIdentity` only connects to identities that already exist. It cannot deploy one on the first user operation. + +The reason is in the factory: it binds `msg.sender` into the deploy salt and links that wallet as the identity's first account. A deployment through a user operation's `initCode` is sent by the **EntryPoint**, so the identity would land at a different address than predicted and would be permanently linked to the EntryPoint rather than to its owner. + +Deploy first with a plain factory transaction ([Deploy an identity](/identity-sdk/guides/deploy-identity)), then connect. + +## Create a bundler client + +Use viem's `createBundlerClient` from `viem/account-abstraction`: + +```ts +import { http } from 'viem'; +import { createBundlerClient } from 'viem/account-abstraction'; + +const bundler = createBundlerClient({ + account, + client, + transport: http(process.env.BUNDLER_URL), +}); +``` + +## The `identityActions()` extension + +`identityActions()` adds one method per identity write. Each method encodes the call with the matching `prepareX` encoder and sends it with `sendUserOperation`, returning the user operation hash. + +```ts +import { identityActions } from '@openzeppelin/identity-sdk'; + +const bundler = createBundlerClient({ account, client, transport: http(process.env.BUNDLER_URL) }).extend( + identityActions(), +); + +const hash = await bundler.addKey({ identity, signerData, purpose: KeyPurposes.ACTION, keyType: KeyTypes.ECDSA }); +const receipt = await bundler.waitForUserOperationReceipt({ hash }); +``` + +| Method | Encoder | Guide | +| ----------------------------- | ------------------------------------ | ----------------------------------------------------- | +| `addKey` | `prepareAddKey` | [Keys](/identity-sdk/guides/keys) | +| `removeKey` | `prepareRemoveKey` | [Keys](/identity-sdk/guides/keys) | +| `addClaim` (see note) | `prepareAddClaim` | [Claims](/identity-sdk/guides/claims) | +| `removeClaim` (see note) | `prepareRemoveClaim` | [Claims](/identity-sdk/guides/claims) | +| `linkAccount` | `prepareLinkAccount` | [Wallet linking](/identity-sdk/guides/wallet-linking) | +| `revokeAccount` | `prepareRevokeAccount` | [Wallet linking](/identity-sdk/guides/wallet-linking) | +| `settlePendingCrossChainLink` | `prepareSettlePendingCrossChainLink` | [Wallet linking](/identity-sdk/guides/wallet-linking) | +| `installModule` | `prepareInstallModule` | [Modules](/identity-sdk/guides/modules) | +| `uninstallModule` | `prepareUninstallModule` | [Modules](/identity-sdk/guides/modules) | + +Note: `addClaim` and `removeClaim` revert when the bundler's account is the holder identity itself, because the registry then sees the identity's own address as the caller, which is not a key. Holders add and remove claims with a direct transaction from a key holder; see [Claims](/identity-sdk/guides/claims). + +The extension has no method for writes that only work as direct transactions from a key holder: the issuer-side `prepareRevokeClaimByDigest` and `prepareAddClaimTo` ([Claims](/identity-sdk/guides/claims#issuer-side-revoke-and-push-claims)) and the ERC-734 `prepareExecute` and `prepareApprove` ([Execution queue](/identity-sdk/guides/execution)). + +Every method also accepts the other `sendUserOperation` parameters (gas limits, fees, nonce, paymaster settings, an explicit `account`), so the gas overrides in [Gas](/identity-sdk/gas) apply here too. + +The same functions are exported standalone (`addKey(client, params)`, `removeKey(client, params)`, and so on) if you prefer not to extend the client. + +The wallet linking methods take the wallet envelope as `wallet` instead of `account`; see [Wallet linking](/identity-sdk/guides/wallet-linking#with-identityactions). + +## Batch several calls in one user operation + +The extension sends one call per user operation. To batch, collect `Call`s from the `prepareX` encoders and pass them to `sendUserOperation` directly. The identity executes them in order, and the whole operation reverts if one call fails. + +```ts +import { keccak256 } from 'viem'; +import { + computeClaimId, + KeyPurposes, + KeyTypes, + prepareAddKey, + prepareRemoveClaim, + prepareRemoveKey, +} from '@openzeppelin/identity-sdk'; + +const calls = [ + prepareAddKey({ identity, signerData: newKey, purpose: KeyPurposes.ACTION, keyType: KeyTypes.ECDSA }), + prepareRemoveKey({ identity, keyHash: keccak256(oldKey), purpose: KeyPurposes.ACTION }), + prepareRemoveClaim({ identity, claimId: computeClaimId({ issuer, topic: 1n }) }), +]; + +const hash = await bundler.sendUserOperation({ calls }); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +You can mix in calls to any other contract (`{ to, data }` or viem's `{ to, abi, functionName, args }` form). The signer needs the strictest purpose any call requires: use `purposeForTargets` to find it (see [Concepts](/identity-sdk/concepts#which-purpose-a-call-needs)). + +```ts +import { purposeForTargets } from '@openzeppelin/identity-sdk'; + +const purpose = await purposeForTargets(client, { identity, targets: calls.map(call => call.to) }); +``` + +## Sponsor gas with a paymaster + +Paymasters are configured on the bundler client, not in the SDK. Use the `paymaster` option of `createBundlerClient`: + +```ts +import { createBundlerClient, createPaymasterClient } from 'viem/account-abstraction'; + +const paymaster = createPaymasterClient({ transport: http(process.env.PAYMASTER_URL) }); + +const bundler = createBundlerClient({ + account, + client, + paymaster, + transport: http(process.env.BUNDLER_URL), +}).extend(identityActions()); +``` + +`paymaster: true` uses the bundler's own transport as an ERC-7677 paymaster. You can also pass `{ getPaymasterData, getPaymasterStubData }` for a custom service. Per-operation paymaster parameters can be passed to `sendUserOperation` or any `identityActions()` method. + +## The identity is `msg.sender` + +When a user operation executes, the call reaches each target **from the identity address**, not from the key that signed it. The signer only authorizes the operation. + +Grant contract roles, allowances, allowlist entries and ownership to the **identity address**. A role granted to the signer's EOA does not apply to calls the identity makes, and a call that relies on it reverts inside the user operation. + +For the same reason, a target that checks `msg.sender` (for example the factory's `linkAccount`) sees the identity, which is what wallet linking relies on. + +## Check the outcome + +A bundle transaction can succeed while the user operation inside it fails. Check `receipt.success` on the user operation receipt rather than the bundle transaction status: + +```ts +const receipt = await bundler.waitForUserOperationReceipt({ hash }); +if (!receipt.success) throw new Error(`user operation reverted: ${receipt.reason ?? 'unknown reason'}`); +``` + +If an operation fails on-chain but succeeds when re-simulated, read [Gas](/identity-sdk/gas). diff --git a/content/identity-sdk/guides/claims.mdx b/content/identity-sdk/guides/claims.mdx new file mode 100644 index 00000000..eb204ed3 --- /dev/null +++ b/content/identity-sdk/guides/claims.mdx @@ -0,0 +1,314 @@ +--- +title: Claims +description: Sign, store, revoke and verify ERC-735 claims +--- + +A claim flows through two parties: + +1. The **issuer** (an identity, usually of type `CLAIM_ISSUER`) signs the claim off-chain with a key that holds `CLAIM_SIGNER` on the issuer identity. +2. The **holder** (the subject identity) stores the signed claim in its own registry. + +Anyone can then read the claim and ask the issuer whether it is still valid. See [Concepts](/identity-sdk/concepts#claims) for the claim fields. + +## Issuer side: sign a claim + +### Read the domain + +Claims are signed as EIP-712 typed data under the **issuer identity's** domain, not the subject's. Read it from the chain with `getClaimDomain`, which uses the issuer's ERC-5267 `eip712Domain()`: + +```ts +import { getClaimDomain } from '@openzeppelin/identity-sdk'; + +const domain = await getClaimDomain(client, { issuer: issuerIdentity }); +``` + +It throws if the issuer publishes no domain. The result only contains the fields the contract declares, which matters: including an undeclared zero `salt` would change the digest. + +### Sign with an EOA key: `signClaim` + +```ts +import { privateKeyToAccount } from 'viem/accounts'; +import { getClaimDomain, signClaim } from '@openzeppelin/identity-sdk'; + +const claimSigner = privateKeyToAccount(process.env.CLAIM_SIGNER_KEY as `0x${string}`); // CLAIM_SIGNER on the issuer + +const topic = 1n; +const scheme = 1n; +const uri = 'https://issuer.example/claims/123'; +const data = { + issuedAt: BigInt(Math.floor(Date.now() / 1000)), + validUntil: 0n, // no expiry + payload: '0x', // topic-specific contents +} as const; + +const signature = await signClaim(claimSigner, { + domain: await getClaimDomain(client, { issuer: issuerIdentity }), + subject: holderIdentity, + topic, + scheme, + uri, + data, +}); +``` + +`signClaim(signer: LocalAccount, params: ClaimSignatureParams)` takes: + +| Field | Type | Notes | +| --------- | ----------------- | ------------------------------------------------------- | +| `domain` | `TypedDataDomain` | From `getClaimDomain` | +| `subject` | `Address` | The holder identity | +| `topic` | `bigint` | | +| `scheme` | `bigint` | Signed through the `Metadata` struct | +| `uri` | `string` | Optional, defaults to `''`; signed through `Metadata` | +| `data` | `ClaimContents` | `{ issuedAt, validUntil, payload }` (no `metadataHash`) | + +The returned value is **not** the raw signature. It is the packed form the registry expects: + +```text +abi.encode(bytes signer, bytes signature) +``` + +where `signer` is the key's 20-byte address. Pass it unchanged to the holder. + +`issuedAt` must be greater than zero, and the claim is not valid before it. `validUntil` of `0n` means no expiry. + +### Sign with any other key: `encodeClaimSignature` + +For passkeys, ERC-1271 wallets or other ERC-7913 signers, sign the typed data yourself and pack the result: + +```ts +import { encodeClaimSignature, getClaimTypedData } from '@openzeppelin/identity-sdk'; + +const typedData = getClaimTypedData({ domain, subject: holderIdentity, topic, scheme, uri, data }); +const rawSignature = await mySigner.signTypedData(typedData); // however your signer signs EIP-712 + +const signature = encodeClaimSignature({ + signer: issuerKeySignerData, // the key's signer data: an address, or verifier(20) || key + signature: rawSignature, +}); +``` + +`signer` must be the signer data of a key holding `CLAIM_SIGNER` on the issuer. `getClaimDigest(params)` returns the EIP-712 digest if your signer signs digests rather than typed data. `CLAIM_TYPES` holds the type map. + +### Metadata hash + +`ClaimData.metadataHash` binds `scheme` and `uri` to the signature. You do not need it for signing (the typed data carries `scheme` and `uri` directly) or for `prepareAddClaim` (which derives it). It is exported for building `ClaimData` by hand, for example to call `isClaimValid` on a claim that is not stored yet: + +```ts +import { getMetadataHash } from '@openzeppelin/identity-sdk'; + +const metadataHash = getMetadataHash({ scheme, uri }); +``` + +## Holder side: store and remove claims + +### Add a claim + +`prepareAddClaim` encodes ERC-735 `addClaim` against the holder identity: + +```ts +prepareAddClaim(params: { + identity: Address; // the holder (the claim's subject) + topic: bigint; + scheme: bigint; + issuer: Address; // the issuer identity + signature: Hex; // from signClaim or encodeClaimSignature + data: ClaimContents; // { issuedAt, validUntil, payload } + uri?: string; // must match the signed uri; defaults to '' +}): Call +``` + +It derives `metadataHash` from `scheme` and `uri`, so the stored `ClaimData` always matches what was signed. The registry re-verifies the signature against the issuer before storing. + +Who may send it: a key on the holder with `CLAIM_SIGNER`, `CLAIM_ADDER` or `MANAGEMENT`, or the claim's own issuer if its reputation passes the registry's trusted-issuer check. The registry checks the address that called the holder identity. + +That means the holder adds claims with a **direct transaction** from a wallet holding one of those keys: + +```ts +import { prepareAddClaim } from '@openzeppelin/identity-sdk'; + +// From a wallet registered on the holder as CLAIM_ADDER, CLAIM_SIGNER or MANAGEMENT. +const txHash = await walletClient.sendTransaction( + prepareAddClaim({ identity: holderIdentity, topic, scheme, issuer: issuerIdentity, signature, data, uri }), +); +``` + +It does **not** work as a user operation from the holder identity itself (`identityActions().addClaim`): there the identity calls itself, so the registry sees the identity's own address as the caller. That address is not a key, and the add reverts with `SenderDoesNotHaveClaimSignerKey`. A user operation works when it comes from the **issuer** identity and targets the holder: the caller is then the claim's issuer, which the trusted-issuer check accepts if the issuer has enough reputation. + +The issuer can also store the claim on the holder itself, with no transaction from the holder: see [Push a claim to its subject](#push-a-claim-to-its-subject-addclaimto). + +Adding a claim with the same `(issuer, topic)` as an existing one overwrites it. That is how renewals work: the issuer signs a new claim with new dates and the holder adds it again. + +For the gas to send with `addClaim`, see [Gas](/identity-sdk/gas): claim verification runs nested inside the identity and is where the 63/64 pitfall was observed. + +### Remove a claim + +```ts +import { computeClaimId, prepareRemoveClaim } from '@openzeppelin/identity-sdk'; + +const claimId = computeClaimId({ issuer: issuerIdentity, topic }); // keccak256(abi.encode(issuer, topic)) + +const txHash = await walletClient.sendTransaction(prepareRemoveClaim({ identity: holderIdentity, claimId })); +``` + +Who may send it: a key on the holder with `CLAIM_SIGNER` or `MANAGEMENT`, as a direct transaction for the same reason as adding (`identityActions().removeClaim` from the holder's own user operation reverts). `CLAIM_ADDER` cannot remove claims. The claim's issuer can also remove its own claim, subject to the trusted-issuer check. It reverts if no claim is stored under `claimId`. + +## Issuer side: revoke and push claims + +Both writes go to the **issuer** identity and check the key of the address that called it. Send them as a **direct transaction** from a wallet holding **MANAGEMENT** on the issuer. From the issuer identity's own user operation the caller is the identity, which holds no key, and the call reverts. + +### Revoke an issued claim + +`prepareRevokeClaimByDigest` revokes one claim by its EIP-712 digest under the issuer's domain: + +```ts +import { + getClaimDigest, + getClaimDomain, + isDigestRevoked, + prepareRevokeClaimByDigest, +} from '@openzeppelin/identity-sdk'; + +const digest = getClaimDigest({ + domain: await getClaimDomain(client, { issuer: issuerIdentity }), + subject: holderIdentity, + topic, + scheme, + uri, + data, // { issuedAt, validUntil, payload }, as signed +}); + +// From a wallet holding MANAGEMENT on the issuer identity. +const txHash = await issuerWalletClient.sendTransaction(prepareRevokeClaimByDigest({ issuer: issuerIdentity, digest })); +await client.waitForTransactionReceipt({ hash: txHash }); + +await isDigestRevoked(client, { issuer: issuerIdentity, digest }); // true +``` + +The digest covers the subject, topic and claim data, so this revokes exactly one claim. A revoked claim stops validating (`getClaimStatus` reports `ClaimStatus.Revoked`), and `addClaim` refuses it, even if the holder already stored it. Revocation is permanent: revoking the same digest again reverts with `ClaimAlreadyRevoked`. + +### Push a claim to its subject: `addClaimTo` + +`prepareAddClaimTo` has the issuer identity store a claim on the subject. The issuer checks that the claim is currently `Valid`, then calls `addClaim` on the subject with itself as the issuer: + +```ts +prepareAddClaimTo(params: { + issuer: Address; // the issuer identity; the transaction goes here + subject: Address; // the identity the claim is about, and where it is stored + topic: bigint; + scheme: bigint; + signature: Hex; // from signClaim or encodeClaimSignature + data: ClaimContents; // { issuedAt, validUntil, payload } + uri?: string; // must match the signed uri; defaults to '' +}): Call +``` + +The subject accepts the claim when it granted the issuer identity a `CLAIM_SIGNER` or `CLAIM_ADDER` key, or when the issuer passes the registry's trusted-issuer check (a `CLAIM_ISSUER` identity with enough reputation). Granting the key is how a holder consents to receive claims from a given issuer: + +```ts +import { getClaimDomain, KeyPurposes, KeyTypes, prepareAddClaimTo, signClaim } from '@openzeppelin/identity-sdk'; + +// 1. The holder grants the issuer identity a CLAIM_ADDER key (a user operation from the holder works). +await holderBundler.addKey({ + identity: holderIdentity, + signerData: issuerIdentity, + purpose: KeyPurposes.CLAIM_ADDER, + keyType: KeyTypes.ECDSA, +}); + +// 2. The issuer signs the claim and pushes it, from a wallet holding MANAGEMENT on the issuer. +const signature = await signClaim(claimSigner, { + domain: await getClaimDomain(client, { issuer: issuerIdentity }), + subject: holderIdentity, + topic, + scheme, + uri, + data, +}); +const txHash = await issuerWalletClient.sendTransaction( + prepareAddClaimTo({ issuer: issuerIdentity, subject: holderIdentity, topic, scheme, signature, data, uri }), +); +``` + +Like `prepareAddClaim`, it derives `metadataHash` from `scheme` and `uri`. + +## Read and verify claims + +### Stored claims + +```ts +import { computeClaimId, getClaim, getClaimIdsByTopic, getClaimIdsByTopicPaginated } from '@openzeppelin/identity-sdk'; + +// One claim. A missing id returns a zeroed record whose issuer is the zero address. +const claim = await getClaim(client, { + identity: holderIdentity, + claimId: computeClaimId({ issuer: issuerIdentity, topic }), +}); +// claim: { topic, scheme, issuer, signature, data: { issuedAt, validUntil, metadataHash, payload }, uri } + +// All claim ids under a topic. +const ids = await getClaimIdsByTopic(client, { identity: holderIdentity, topic }); + +// A page of them: positions [start, end), clamped to the number of ids. +const page = await getClaimIdsByTopicPaginated(client, { identity: holderIdentity, topic, start: 0n, end: 20n }); +``` + +`getClaimIdsByTopicPaginated` calls the registry module directly with `from` set to the identity, so it needs an RPC that honors `from` in `eth_call` (most do). + +### Validity + +A stored claim is not necessarily a valid one. Ask the issuer: + +```ts +import { ClaimStatus, getClaimStatus, isClaimValid, isDigestRevoked, getClaimDigest } from '@openzeppelin/identity-sdk'; + +const check = { + issuer: claim.issuer, + subject: holderIdentity, + topic: claim.topic, + signature: claim.signature, + data: claim.data, // includes metadataHash, as returned by getClaim +}; + +const valid = await isClaimValid(client, check); // boolean + +const status = await getClaimStatus(client, check); +if (status === ClaimStatus.Expired) { + // ask the issuer for a renewal +} +``` + +`isClaimValid` and `getClaimStatus` take `ClaimCheckParams`: `{ issuer, subject, topic, signature, data }`. + +`getClaimStatus` returns the first failing check, in this order: + +| Check | `ClaimStatus` | +| ----------------------------------------- | -------------- | +| `issuedAt == 0` | `BadSignature` | +| before `issuedAt` | `NotYetValid` | +| after `validUntil` (when non-zero) | `Expired` | +| digest revoked by the issuer | `Revoked` | +| malformed signature | `BadSignature` | +| signer lacks `CLAIM_SIGNER` on the issuer | `NotIssued` | +| signature does not verify | `BadSignature` | +| all checks pass | `Valid` | + +Like the paginated read, `getClaimStatus` calls the issuer's registry module with `from` set to the issuer. + +To check revocation alone, compute the digest and ask the issuer: + +```ts +const digest = getClaimDigest({ + domain: await getClaimDomain(client, { issuer: claim.issuer }), + subject: holderIdentity, + topic: claim.topic, + scheme: claim.scheme, + uri: claim.uri, + data: { issuedAt: claim.data.issuedAt, validUntil: claim.data.validUntil, payload: claim.data.payload }, +}); +const revoked = await isDigestRevoked(client, { issuer: claim.issuer, digest }); +``` + +### Decoding payloads + +The meaning of `payload` depends on the topic. The `IdentityUtilities` contract keeps a schema per topic (field names and types); read it with `iidentityUtilitiesAbi` (`getSchema(topicId)`) and decode `payload` with viem's `decodeAbiParameters`. diff --git a/content/identity-sdk/guides/deploy-identity.mdx b/content/identity-sdk/guides/deploy-identity.mdx new file mode 100644 index 00000000..8a159e5e --- /dev/null +++ b/content/identity-sdk/guides/deploy-identity.mdx @@ -0,0 +1,162 @@ +--- +title: Deploy an Identity +description: Predict an identity address and deploy it through the IdentityFactory +--- + +Identities are deployed by the `IdentityFactory` with a plain transaction. The SDK builds the calldata and predicts the address; you send it with a viem wallet client. + +Deployment is always a separate step from connecting with `toIdentity`. See [Account abstraction](/identity-sdk/guides/account-abstraction#connect-only) for why an identity cannot be deployed from its own first user operation. + +## Build the initial keys + +The factory takes an array of `KeyParam`: + +```ts +type KeyParam = { + keyHash: Hex; // keccak256(signerData) + purpose: bigint; + keyType: bigint; + signerData: Hex; + clientData: Hex; +}; +``` + +Build entries with `toKeyParam`, which derives `keyHash` from `signerData` and defaults `clientData` to `0x`: + +```ts +import { KeyPurposes, KeyTypes, toKeyParam } from '@openzeppelin/identity-sdk'; + +const keys = [ + toKeyParam({ signerData: owner.address, purpose: KeyPurposes.MANAGEMENT, keyType: KeyTypes.ECDSA }), + toKeyParam({ signerData: owner.address, purpose: KeyPurposes.ACTION, keyType: KeyTypes.ECDSA }), +]; +``` + +One entry grants one purpose. To give the same key several purposes, add one entry per purpose with the same `keyType`. The keys must include at least one MANAGEMENT key. + +The whole array is hashed into the deploy salt, so the same keys in a different order produce a different address. Use the same array for prediction and deployment. + +## Predict the address + +`computeIdentityAddress` is pure (no chain reads). It reproduces the factory's derivation exactly: + +```ts +import { computeIdentityAddress, IdentityTypes } from '@openzeppelin/identity-sdk'; + +const predicted = computeIdentityAddress({ + factory, + identityType: IdentityTypes.INDIVIDUAL, + salt: 'alice-v1', + account: owner.address, // the wallet the factory links to the identity + keys, +}); +``` + +`account` is the wallet the factory links to the new identity: + +- for `createIdentity`, the transaction sender; +- for `createIdentityFor`, the `account` argument. + +`salt` is any non-empty string you choose. Use a different salt to deploy another identity with the same keys for the same wallet. + +The predicted address is stable through the deploy, so you can use it before deployment, for example to have another wallet sign a `LinkAccount` message for it in advance (see [Wallet linking](/identity-sdk/guides/wallet-linking)). + +`computeDeploySalt` (the inner salt) and `computeCreate3Address` (the generic CREATE3 derivation) are also exported if you need the intermediate steps. + +## Self-deploy: `prepareCreateIdentity` + +The sender deploys an identity for itself. The factory links `msg.sender` to the new identity, so send it from the wallet that should own it. + +Who may call it: anyone, for identity types whose factory policy is **self-deployable**. Other types revert. + +```ts +import { createPublicClient, createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { baseSepolia } from 'viem/chains'; +import { + computeIdentityAddress, + IdentityTypes, + identityTypeOf, + isFactoryIdentity, + KeyPurposes, + KeyTypes, + prepareCreateIdentity, + toKeyParam, +} from '@openzeppelin/identity-sdk'; + +const factory = '0x...'; // IdentityFactory address +const owner = privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`); + +const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); +const walletClient = createWalletClient({ account: owner, chain: baseSepolia, transport: http() }); + +const identityType = IdentityTypes.INDIVIDUAL; +const salt = 'alice-v1'; +const keys = [toKeyParam({ signerData: owner.address, purpose: KeyPurposes.MANAGEMENT, keyType: KeyTypes.ECDSA })]; + +const identity = computeIdentityAddress({ factory, identityType, salt, account: owner.address, keys }); + +const call = prepareCreateIdentity({ factory, identityType, salt, keys }); +const hash = await walletClient.sendTransaction(call); +await publicClient.waitForTransactionReceipt({ hash }); + +// Confirm the deployment. +console.log(await isFactoryIdentity(publicClient, { factory, identity })); // true +console.log(await identityTypeOf(publicClient, { factory, identity })); // 2n (INDIVIDUAL) +``` + +The factory installs the modules registered for `identityType`; you do not pass them. + +## Deploy for someone else: `prepareCreateIdentityFor` + +An operator deploys an identity on behalf of `account`, and the factory links `account` to it. + +Who may call it: a sender holding the AccessManager role that the identity type's policy names, granted **without an execution delay**. The policy is readable with `getIdentityTypePolicy` on `iidentityFactoryAbi`. + +Key constraints: + +- For identity types that hold keys, every key must be `account`'s own (`signerData` is `account`'s address), and `account` must end up as the **sole** MANAGEMENT key. +- Contract-backed types (single-binding policies, such as a token's `ASSET` identity) skip that check. + +```ts +import { + computeIdentityAddress, + IdentityTypes, + KeyPurposes, + KeyTypes, + prepareCreateIdentityFor, + toKeyParam, +} from '@openzeppelin/identity-sdk'; + +const investor = '0x...'; // the wallet the identity is created for +const keys = [toKeyParam({ signerData: investor, purpose: KeyPurposes.MANAGEMENT, keyType: KeyTypes.ECDSA })]; + +const identity = computeIdentityAddress({ + factory, + identityType: IdentityTypes.INDIVIDUAL, + salt: 'investor-001', + account: investor, + keys, +}); + +const hash = await operatorWalletClient.sendTransaction( + prepareCreateIdentityFor({ + factory, + account: investor, + identityType: IdentityTypes.INDIVIDUAL, + salt: 'investor-001', + keys, + }), +); +await publicClient.waitForTransactionReceipt({ hash }); +``` + +## Sending from a smart account + +`prepareCreateIdentity` returns a `Call`, so it can also be sent as a user operation from an existing smart account. In that case `msg.sender` at the factory is that smart account: it becomes the linked wallet and must be the `account` passed to `computeIdentityAddress`. This is different from deploying through the new identity's own `initCode`, which is not possible. + +## After deployment + +- Connect to the identity with `toIdentity` to send user operations: [Account abstraction](/identity-sdk/guides/account-abstraction). +- Link more wallets: [Wallet linking](/identity-sdk/guides/wallet-linking). +- Pass an explicit `gas` with headroom if the deployment runs near its estimate: [Gas](/identity-sdk/gas). diff --git a/content/identity-sdk/guides/execution.mdx b/content/identity-sdk/guides/execution.mdx new file mode 100644 index 00000000..b223fac0 --- /dev/null +++ b/content/identity-sdk/guides/execution.mdx @@ -0,0 +1,124 @@ +--- +title: Execution Queue +description: Queue, approve and track ERC-734 executions with direct transactions from key holders +--- + +Besides user operations, an identity can make calls through the ERC-734 execution queue. A key holder asks the identity to call a target; the call either runs at once or waits for approval, depending on the key's purpose and the target. The queue is served by the `KeyApprovalModule`, which the identity reaches through its ERC-7579 fallback. + +Use the queue when a wallet holding a key should make the identity act with a plain transaction, or when a call needs a second key's approval (a `PROPOSER` key queues, a `MANAGEMENT` or `ACTION` key approves). Under account abstraction there is nothing to queue: a user operation calls the target directly. See [Account abstraction](/identity-sdk/guides/account-abstraction). + + + `execute` and `approve` must be **direct transactions** from a wallet holding a key on the identity. The module checks + the key of the address that called the identity. Sent from the identity's own user operation, the caller is the + identity itself, which holds no key, and the call is refused. That is why `identityActions()` has no `execute` or + `approve` method. + + +## Queue a call: `prepareExecute` + +```ts +prepareExecute(params: { + identity: Address; // the identity that will make the call + to: Address; // the call's target + value?: bigint; // wei the identity sends from its own balance; defaults to 0n + data?: Hex; // the call's calldata; defaults to 0x +}): Call +``` + +Who may queue, and when the call runs: + +- Any key with `PROPOSER`, `ACTION`, `CLAIM_SIGNER`, `CLAIM_ADDER` or `MANAGEMENT` can queue. +- The call runs immediately when the sender holds `MANAGEMENT`, or holds `ACTION` and `to` is an external target. +- A management target (the identity itself, its factory or its registry module; see `isManagementTarget`) never runs immediately below `MANAGEMENT`. +- Otherwise the call waits for [`prepareApprove`](#approve-or-reject-prepareapprove). + +`value` is paid from the identity's balance when the call runs. Wei attached to the `execute` transaction itself is returned to the identity, not forwarded. + +```ts +import { createWalletClient, http, parseEther } from 'viem'; +import { getExecution, parseExecutionRequests, prepareExecute } from '@openzeppelin/identity-sdk'; + +// `actor` holds ACTION on the identity, and `recipient` is an external address: the call runs at once. +const walletClient = createWalletClient({ account: actor, chain, transport: http() }); + +const hash = await walletClient.sendTransaction(prepareExecute({ identity, to: recipient, value: parseEther('1') })); +const receipt = await client.waitForTransactionReceipt({ hash }); + +const [request] = parseExecutionRequests(receipt.logs); +// { identity, executionId, to, value, data, proposer } +``` + +### Execution ids + +Each queued call gets the identity's current execution nonce as its id. Ids start at 0. + +- `getExecutionNonce(client, { identity })` returns the id the next queued call will get. +- `parseExecutionRequests(logs)` decodes the `ExecutionRequested` events in a set of logs, such as a receipt's, and returns `{ identity, executionId, to, value, data, proposer }` for each. This is the reliable way to learn the id a `prepareExecute` transaction used. + +### A failed call does not revert + +A call that runs and fails does not revert the `execute` transaction: the module records it with `succeeded: false` and emits `ExecutionFailed`. Check `getExecution` or the events, not the receipt status. The call runs nested inside the identity, so leave gas headroom; see [Gas](/identity-sdk/gas). + +## Approve or reject: `prepareApprove` + +```ts +prepareApprove(params: { + identity: Address; + executionId: bigint; + approve: boolean; // true approves and runs the call, false rejects it +}): Call +``` + +The sender needs `MANAGEMENT` when the queued target is a management target, and `ACTION` otherwise. Approving also requires the key that queued the request to still be allowed to queue; rejecting closes the request either way. + +A `PROPOSER` key queues, and a `MANAGEMENT` key approves: + +```ts +import { parseEther } from 'viem'; +import { getExecution, parseExecutionRequests, prepareApprove, prepareExecute } from '@openzeppelin/identity-sdk'; + +// 1. The proposer queues the call. A PROPOSER key cannot run it. +const queued = await client.waitForTransactionReceipt({ + hash: await proposerWallet.sendTransaction(prepareExecute({ identity, to: recipient, value: parseEther('1') })), +}); +const { executionId } = parseExecutionRequests(queued.logs)[0]; + +// 2. A MANAGEMENT key approves it, which runs the call. +const approved = await client.waitForTransactionReceipt({ + hash: await managerWallet.sendTransaction(prepareApprove({ identity, executionId, approve: true })), +}); + +// 3. Check the outcome. +const execution = await getExecution(client, { module: keyApprovalModule, identity, executionId }); +execution.succeeded; // true if the call landed +``` + +## Read an execution: `getExecution` + +```ts +getExecution(client, params: { module: Address; identity: Address; executionId: bigint }): Promise +``` + +The identity does not route this read through its fallback, so `getExecution` calls the `KeyApprovalModule` itself, which takes the identity as an argument. `module` is the `KeyApprovalModule` deployment the identity uses. The factory lists the modules it installs for each identity type (`getIdentityTypeModules` on `iidentityFactoryAbi`). + +`ExecutionRecord` is `{ to, value, data, proposer, approved, executed, succeeded }`: + +| State | `approved` | `executed` | `succeeded` | +| -------- | ---------- | ---------- | ------------------------------ | +| Pending | `false` | `false` | `false` | +| Rejected | `false` | `true` | `false` | +| Ran | `true` | `true` | whether the call itself landed | + +An id that was never queued reads as a zeroed record. A zero `proposer` is the tell, since queueing requires a key. + +Uninstalling any part of the `KeyApprovalModule` voids its pending executions ([Modules](/identity-sdk/guides/modules#uninstall-a-module)). Voided requests still read as pending, and `approve` refuses them with `RequestInvalidated`. + +## Reference + +| Function | Parameters | Returns | +| ------------------------ | ------------------------------------------- | -------------------- | +| `prepareExecute` | `{ identity, to, value?, data? }` | `Call` | +| `prepareApprove` | `{ identity, executionId, approve }` | `Call` | +| `getExecutionNonce` | `client, { identity }` | `bigint` | +| `getExecution` | `client, { module, identity, executionId }` | `ExecutionRecord` | +| `parseExecutionRequests` | `logs` | `ExecutionRequest[]` | diff --git a/content/identity-sdk/guides/keys.mdx b/content/identity-sdk/guides/keys.mdx new file mode 100644 index 00000000..865e33e8 --- /dev/null +++ b/content/identity-sdk/guides/keys.mdx @@ -0,0 +1,211 @@ +--- +title: Keys +description: Add, remove and read ERC-734 keys on an identity +--- + +Keys live in the identity's ERC-734 registry. Each key is stored under `keyHash = keccak256(signerData)` and holds one or more purposes. See [Concepts](/identity-sdk/concepts#keys) for signer data, key types and purposes. + +Key writes target the identity itself, so the sender needs **MANAGEMENT** on the identity. + +## Add a key + +`prepareAddKey` registers a new key with one purpose. It encodes the account-level `addKeyWithData`, which carries the signer bytes; the plain ERC-734 `addKey(bytes32, uint256, uint256)` reverts for a key the registry has never seen. + +```ts +prepareAddKey(params: { + identity: Address; + signerData: Hex; // 20-byte address, or verifier(20) || key + purpose: bigint | KeyPurpose; + keyType: bigint | KeyType; + clientData?: Hex; // optional metadata, defaults to 0x +}): Call +``` + +The encoder computes `keyHash` from `signerData` itself, so the hash and bytes can never disagree. + +### As a user operation + +```ts +import { createPublicClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createBundlerClient } from 'viem/account-abstraction'; +import { baseSepolia } from 'viem/chains'; +import { identityActions, KeyPurposes, KeyTypes, toIdentity } from '@openzeppelin/identity-sdk'; + +const client = createPublicClient({ chain: baseSepolia, transport: http() }); +const manager = privateKeyToAccount(process.env.MANAGER_KEY as `0x${string}`); // holds MANAGEMENT + +const bundler = createBundlerClient({ + account: await toIdentity(client, { address: identity, signer: manager }), + client, + transport: http(process.env.BUNDLER_URL), +}).extend(identityActions()); + +const hash = await bundler.addKey({ + identity, + signerData: '0x...', // the new key's address + purpose: KeyPurposes.ACTION, + keyType: KeyTypes.ECDSA, +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +### As a direct transaction + +A wallet whose address is registered as a MANAGEMENT key on the identity can send the same `Call` itself: + +```ts +import { createWalletClient } from 'viem'; +import { prepareAddKey } from '@openzeppelin/identity-sdk'; + +const walletClient = createWalletClient({ account: manager, chain: baseSepolia, transport: http() }); + +const hash = await walletClient.sendTransaction( + prepareAddKey({ identity, signerData: newKey, purpose: KeyPurposes.ACTION, keyType: KeyTypes.ECDSA }), +); +await client.waitForTransactionReceipt({ hash }); +``` + +### Several purposes + +`prepareAddKey` grants one purpose per call. Calling it again for a key that is already registered adds the new purpose to that key. The `keyType` must match the stored one, the stored `clientData` is kept, and granting a purpose the key already holds reverts. + +To register a key with several purposes in one user operation, batch the calls: + +```ts +await bundler.sendUserOperation({ + calls: [ + prepareAddKey({ identity, signerData: newKey, purpose: KeyPurposes.ACTION, keyType: KeyTypes.ECDSA }), + prepareAddKey({ identity, signerData: newKey, purpose: KeyPurposes.CLAIM_ADDER, keyType: KeyTypes.ECDSA }), + ], +}); +``` + +ERC-734's `addKey(bytes32, uint256, uint256)` also grants a purpose to an existing key (it looks up the stored signer data by hash, and reverts for an unknown key). The SDK has no encoder for it; encode it with the vendored ABI if you need that entry point: + +```ts +import { encodeFunctionData, keccak256 } from 'viem'; +import { iidentityAbi, KeyPurposes, KeyTypes } from '@openzeppelin/identity-sdk'; + +const grant = { + to: identity, + data: encodeFunctionData({ + abi: iidentityAbi, + functionName: 'addKey', + args: [keccak256(existingKey), KeyPurposes.CLAIM_SIGNER, KeyTypes.ECDSA], + }), +}; +``` + +### Non-EOA keys + +For passkeys, RSA or other ERC-7913 signers, `signerData` is `verifier (20 bytes) || key`, and `keyType` names the scheme (`KeyTypes.WEBAUTHN`, `KeyTypes.RSA`). Use `clientData` for non-cryptographic metadata such as a WebAuthn credential id. + +`encodeVerifierSigner({ verifier, key })` builds the signer data. It throws when `key` is empty or the result exceeds the registry's 1024-byte limit: + +```ts +import { encodeVerifierSigner, KeyPurposes, KeyTypes, prepareAddKey } from '@openzeppelin/identity-sdk'; + +const signerData = encodeVerifierSigner({ verifier: rsaVerifier, key: rsaKey }); +const call = prepareAddKey({ identity, signerData, purpose: KeyPurposes.ACTION, keyType: KeyTypes.RSA }); +``` + +For WebAuthn passkeys, `prepareAddPasskeyKey` does this from the P-256 public key. See [Passkeys](/identity-sdk/guides/passkeys). + +To let an ERC-7579 executor module act for the identity, register the module's address with `KeyTypes.MODULE`. See [Modules](/identity-sdk/guides/modules). + +## Remove a key + +`prepareRemoveKey` removes one purpose from a key: + +```ts +prepareRemoveKey(params: { identity: Address; keyHash: Hex; purpose: bigint | KeyPurpose }): Call +``` + +- Removing the key's **last** purpose unregisters the key. +- Removing the identity's **last** MANAGEMENT key reverts. +- Removing a purpose the key does not hold reverts. + +```ts +import { keccak256 } from 'viem'; +import { KeyPurposes } from '@openzeppelin/identity-sdk'; + +const hash = await bundler.removeKey({ + identity, + keyHash: keccak256(oldKey), + purpose: KeyPurposes.ACTION, +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +`keyHash` is `keccak256(signerData)`. Reads such as `getKeysByPurpose` return these hashes directly. + +To rotate a key, batch the add and the remove in one user operation so the identity is never left without the purpose: + +```ts +await bundler.sendUserOperation({ + calls: [ + prepareAddKey({ identity, signerData: newKey, purpose: KeyPurposes.MANAGEMENT, keyType: KeyTypes.ECDSA }), + prepareRemoveKey({ identity, keyHash: keccak256(oldKey), purpose: KeyPurposes.MANAGEMENT }), + ], +}); +``` + +If the old key is the one signing the operation, it can still sign this operation: validation happens before execution. + +## Read keys + +All key reads take `{ identity }` (`KeyReadParams`) and are sent to the identity. `getKeyData` is the exception: it reads the identity's `registryModule()` and then calls the module. + +```ts +import { keccak256 } from 'viem'; +import { + getKey, + getKeyData, + getKeyPurposes, + getKeysByPurpose, + getKeysByPurposePaginated, + keyHasPurpose, + KeyPurposes, +} from '@openzeppelin/identity-sdk'; + +const key = keccak256(signerData); + +// Full record: { purposes, keyType, keyHash }. Reverts if the key is not registered. +const record = await getKey(client, { identity, key }); + +// Purposes the key holds. +const purposes = await getKeyPurposes(client, { identity, key }); // readonly bigint[] + +// Key hashes registered under a purpose. +const managers = await getKeysByPurpose(client, { identity, purpose: KeyPurposes.MANAGEMENT }); // readonly Hex[] + +// The same, one page at a time: positions [start, end), clamped to the number of keys. +const page = await getKeysByPurposePaginated(client, { + identity, + purpose: KeyPurposes.ACTION, + start: 0n, + end: 50n, +}); + +// Purpose check. True if the key holds the purpose or holds MANAGEMENT. +const canAct = await keyHasPurpose(client, { identity, key, purpose: KeyPurposes.ACTION }); + +// The bytes the key was registered with. Both are 0x for an unknown hash. +const { signerData: stored, clientData } = await getKeyData(client, { identity, key }); +``` + +| Function | Parameters | Returns | +| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------- | +| `getKey` | `{ identity, key }` | `KeyRecord` (`{ purposes, keyType, keyHash }`); reverts for an unknown key | +| `getKeyPurposes` | `{ identity, key }` | `readonly bigint[]` | +| `getKeyPurposesPaginated` | `{ identity, key, start, end }` | `readonly bigint[]` | +| `getKeysByPurpose` | `{ identity, purpose }` | `readonly Hex[]` (key hashes) | +| `getKeysByPurposePaginated` | `{ identity, purpose, start, end }` | `readonly Hex[]` (key hashes) | +| `keyHasPurpose` | `{ identity, key, purpose }` | `boolean` | +| `getKeyData` | `{ identity, key }` | `{ signerData, clientData }`; both `0x` for an unknown key | +| `getIdentityType` | `{ identity }` | `bigint` (`IdentityTypes`) | + +`key` is always a key hash, not the signer data. The paginated reads return positions `[start, end)`, clamped to the number of entries; use them for purposes with many keys. + +`getIdentityType` returns the type the factory recorded at deployment. diff --git a/content/identity-sdk/guides/modules.mdx b/content/identity-sdk/guides/modules.mdx new file mode 100644 index 00000000..c2bf1c0d --- /dev/null +++ b/content/identity-sdk/guides/modules.mdx @@ -0,0 +1,144 @@ +--- +title: Modules +description: Install, uninstall and check ERC-7579 modules on an identity +--- + +Every identity is an ERC-7579 modular account. The factory installs the modules registered for the identity's type at deployment (see [Concepts](/identity-sdk/concepts#the-factory)). These functions manage modules afterwards. + +Module writes target the identity itself, so they need **MANAGEMENT** or a call from the identity. They work both as a direct transaction from a wallet holding MANAGEMENT and from the identity's own user operation (`identityActions().installModule` / `uninstallModule`). + +Module types are ERC-7579 ids, exported as `ModuleTypes`: + +| `ModuleTypes` | Value | +| ------------- | ----- | +| `VALIDATOR` | `1n` | +| `EXECUTOR` | `2n` | +| `FALLBACK` | `3n` | +| `HOOK` | `4n` | + +## Install a module + +```ts +prepareInstallModule(params: { + identity: Address; + moduleType: bigint | ModuleType; // ModuleTypes.* + module: Address; + initData?: Hex; // passed to the module's onInstall; defaults to 0x +}): Call +``` + +As a user operation: + +```ts +import { ModuleTypes } from '@openzeppelin/identity-sdk'; + +const hash = await bundler.installModule({ + identity, + moduleType: ModuleTypes.EXECUTOR, + module: executorModule, + initData: '0x', +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +As a direct transaction from a MANAGEMENT key: + +```ts +import { ModuleTypes, prepareInstallModule } from '@openzeppelin/identity-sdk'; + +const hash = await walletClient.sendTransaction( + prepareInstallModule({ identity, moduleType: ModuleTypes.EXECUTOR, module: executorModule }), +); +``` + +Installing a module does not give it a key: + +- An **executor** acts through `executeFromExecutor` only once it also holds a key of type `MODULE` with a purpose. Register it with `prepareAddKey({ identity, signerData: module, purpose, keyType: KeyTypes.MODULE })`, in the same user operation if you like. +- A **validator** lets its signers authorize user operations for the identity. Only install validators that scope their keys. + +```ts +import { KeyPurposes, KeyTypes, ModuleTypes, prepareAddKey, prepareInstallModule } from '@openzeppelin/identity-sdk'; + +await bundler.sendUserOperation({ + calls: [ + prepareInstallModule({ identity, moduleType: ModuleTypes.EXECUTOR, module: executorModule }), + prepareAddKey({ identity, signerData: executorModule, purpose: KeyPurposes.ACTION, keyType: KeyTypes.MODULE }), + ], +}); +``` + +## Fallback handlers + +A fallback handler serves specific function selectors on the identity. Its install and uninstall data start with the 4-byte selector to route or unroute, followed by any data for the module itself. `encodeFallbackInitData` builds it: + +```ts +import { toFunctionSelector } from 'viem'; +import { encodeFallbackInitData, ModuleTypes } from '@openzeppelin/identity-sdk'; + +const selector = toFunctionSelector('getCurrentNonce()'); + +await bundler.installModule({ + identity, + moduleType: ModuleTypes.FALLBACK, + module: handler, + initData: encodeFallbackInitData({ selector }), // { selector, data? } +}); +``` + +Each install or uninstall routes one selector. A handler that serves several selectors is installed once per selector. + +## Uninstall a module + +```ts +prepareUninstallModule(params: { + identity: Address; + moduleType: bigint | ModuleType; + module: Address; + deInitData?: Hex; // passed to the module's onUninstall; defaults to 0x +}): Call +``` + +```ts +await bundler.uninstallModule({ + identity, + moduleType: ModuleTypes.FALLBACK, + module: handler, + deInitData: encodeFallbackInitData({ selector }), +}); +``` + +What the identity does on uninstall: + +- For every module type except fallback handlers, it first strips every ERC-734 purpose the module's key holds. +- A fallback uninstall removes one selector only and keeps the module's key, since the module may still serve other selectors. Remove the key with `prepareRemoveKey` when retiring the module. +- Uninstalling any part of the `KeyApprovalModule` voids its pending executions (see [Execution queue](/identity-sdk/guides/execution)). + + + The ERC-734 execution queue (`execute`, `approve`, `getCurrentNonce`) reaches the `KeyApprovalModule` through fallback + routes on the identity. Uninstalling one of those routes makes the matching SDK calls (`prepareExecute`, + `prepareApprove`, `getExecutionNonce`) revert on that identity until it is installed again. + + +## Check a module + +```ts +import { isModuleInstalled, ModuleTypes } from '@openzeppelin/identity-sdk'; + +const installed = await isModuleInstalled(client, { + identity, + moduleType: ModuleTypes.FALLBACK, + module: handler, + additionalContext: selector, // for a fallback handler, the selector to check; defaults to 0x +}); +``` + +## Reference + +| Function | Parameters | Returns | +| ------------------------- | -------------------------------------------------------------------- | ------------------- | +| `prepareInstallModule` | `{ identity, moduleType, module, initData? }` | `Call` | +| `prepareUninstallModule` | `{ identity, moduleType, module, deInitData? }` | `Call` | +| `isModuleInstalled` | `client, { identity, moduleType, module, additionalContext? }` | `boolean` | +| `encodeFallbackInitData` | `{ selector, data? }` | `Hex` | +| `bundler.installModule` | `prepareInstallModule` parameters plus `sendUserOperation` options | user operation hash | +| `bundler.uninstallModule` | `prepareUninstallModule` parameters plus `sendUserOperation` options | user operation hash | diff --git a/content/identity-sdk/guides/passkeys.mdx b/content/identity-sdk/guides/passkeys.mdx new file mode 100644 index 00000000..8420ff8f --- /dev/null +++ b/content/identity-sdk/guides/passkeys.mdx @@ -0,0 +1,110 @@ +--- +title: Passkeys +description: Register a WebAuthn passkey as an identity key and sign user operations with it +--- + +A WebAuthn passkey can hold a key on an identity like an EOA. The registry checks it through an ERC-7913 verifier contract: the key's signer data is `verifier (20 bytes) || qx || qy`, where `qx || qy` is the passkey's P-256 public key. See [Concepts](/identity-sdk/concepts#keys) for signer data. + +You need the address of the WebAuthn verifier the deployment trusts (`ERC7913WebAuthnVerifier`, logged by ONCHAINID's deploy script). + +## Register a passkey + +`prepareAddPasskeyKey` is `prepareAddKey` with `keyType: KeyTypes.WEBAUTHN` and the signer data built from the public key: + +```ts +prepareAddPasskeyKey(params: { + identity: Address; + verifier: Address; // the ERC-7913 WebAuthn verifier + publicKey: Hex; // 64 bytes qx || qy, or 65 bytes uncompressed with the leading 0x04 + purpose: bigint | KeyPurpose; + clientData?: Hex; // optional metadata stored with the key, such as the credential id +}): Call +``` + +It throws for a public key of any other length. Like any key write, it needs MANAGEMENT on the identity, as a user operation or a direct transaction: + +```ts +import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction'; +import { KeyPurposes, prepareAddPasskeyKey } from '@openzeppelin/identity-sdk'; + +const credential = await createWebAuthnCredential({ name: 'identity' }); +const passkey = toWebAuthnAccount({ credential }); + +// `bundler` is signed by an existing MANAGEMENT key. +const hash = await bundler.sendUserOperation({ + calls: [ + prepareAddPasskeyKey({ + identity, + verifier: webAuthnVerifier, + publicKey: passkey.publicKey, + purpose: KeyPurposes.MANAGEMENT, + }), + ], +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +## Build the signer data + +To sign with the passkey or read its key, you need the same signer data the registry stored. Build it with `encodeVerifierSigner` from the 64-byte `qx || qy` form of the public key: + +```ts +import { keccak256, sliceHex, size } from 'viem'; +import { encodeVerifierSigner, getKeyData } from '@openzeppelin/identity-sdk'; + +// Drop the leading 0x04 from a 65-byte uncompressed key. +const rawKey = size(passkey.publicKey) === 65 ? sliceHex(passkey.publicKey, 1) : passkey.publicKey; +const signerData = encodeVerifierSigner({ verifier: webAuthnVerifier, key: rawKey }); + +const keyHash = keccak256(signerData); +const { signerData: stored } = await getKeyData(client, { identity, key: keyHash }); // equals signerData +``` + +## Sign user operations with a passkey + +Pass the viem `WebAuthnAccount` to `toIdentity` as the signer, together with `signerData`. A `WebAuthnAccount` has no address, so `toIdentity` cannot derive the signer data on its own and throws without it. + +```ts +import { createBundlerClient } from 'viem/account-abstraction'; +import { http } from 'viem'; +import { identityActions, toIdentity } from '@openzeppelin/identity-sdk'; + +const account = await toIdentity(client, { + address: identity, + signer: passkey, + signerData, + userOperation: { + // The stub signature is ECDSA-sized, so the bundler under-estimates validation for a passkey. + estimateGas: async () => ({ verificationGasLimit: 2_000_000n, preVerificationGas: 300_000n }), + }, +}); + +const passkeyBundler = createBundlerClient({ + account, + client, + transport: http(process.env.BUNDLER_URL), +}).extend(identityActions()); +``` + +Set the gas limits explicitly. To estimate validation gas, the bundler runs it with the stub signature `toIdentity` provides, which is sized like a 65-byte ECDSA signature. A WebAuthn signature is much longer, and verifying it can cost far more, so the estimates for `verificationGasLimit` and `preVerificationGas` come out too low. The values above are the ones the SDK's live tests use on a chain without a P-256 precompile, where the curve is verified in Solidity. Size them for your chain and verifier. See [Gas](/identity-sdk/gas#user-operations). + +The passkey signs with the purposes its key holds, like any other key: a MANAGEMENT passkey can manage keys, modules and wallet links. + +```ts +const hash = await passkeyBundler.addKey({ + identity, + signerData: newKeyAddress, + purpose: KeyPurposes.ACTION, + keyType: KeyTypes.ECDSA, +}); +await passkeyBundler.waitForUserOperationReceipt({ hash }); +``` + +## Other ERC-7913 signers + +The same pattern works for any signer checked by an ERC-7913 verifier (RSA, for example): + +1. Register the key with `prepareAddKey`, `signerData: encodeVerifierSigner({ verifier, key })` and the matching `keyType`. +2. Connect with `toIdentity`, passing an accounts SDK `Signer` (or pass `signerData` explicitly), and set the gas limits for the longer signature. + +See [Keys](/identity-sdk/guides/keys#non-eoa-keys). diff --git a/content/identity-sdk/guides/wallet-linking.mdx b/content/identity-sdk/guides/wallet-linking.mdx new file mode 100644 index 00000000..aea8a560 --- /dev/null +++ b/content/identity-sdk/guides/wallet-linking.mdx @@ -0,0 +1,197 @@ +--- +title: Wallet Linking +description: Link, revoke and resolve wallets with ERC-7930 envelopes, including cross-chain links +--- + +The `IdentityFactory` maps wallets to identities. Integrators use it in two directions: + +- **Resolve**: given a wallet, find its identity (for example, to look up claims for the wallet that is about to receive tokens). +- **Manage**: link further wallets to an identity, revoke them, and accept wallets proposed from other chains. + +Wallets are identified by ERC-7930 envelopes, not bare addresses. See [Concepts](/identity-sdk/concepts#wallet-linking). + +## Build the envelope + +```ts +import { formatEvmV1 } from '@openzeppelin/identity-sdk'; + +const envelope = formatEvmV1({ chainId: baseSepolia.id, address: wallet.address }); +``` + +`chainId` accepts `number` or `bigint`. The factory canonicalizes envelopes, so differently padded encodings of the same wallet are the same entry. + +## Resolve a wallet + +All resolution reads take `AccountReadParams`: `{ factory, account }`, where `account` is the envelope. + +```ts +import { + AccountStatus, + getAccountStatus, + getIdentity, + getIdentityIncludingRevoked, + getIdentityWithType, +} from '@openzeppelin/identity-sdk'; + +const params = { factory, account: envelope }; + +// Active link only. Zero address when never linked or revoked. +const identity = await getIdentity(client, params); + +// Identity and type in one read. Zero address and 0n when not Active. +const { identity: id, identityType } = await getIdentityWithType(client, params); + +// Includes revoked links: a revoked wallet still names its former identity. +const { identity: former, status } = await getIdentityIncludingRevoked(client, params); + +// Status only: AccountStatus.None, Active or Revoked. +const current = await getAccountStatus(client, params); +if (current === AccountStatus.Revoked) { + // this wallet can never be linked again +} +``` + +A factory identity's own address resolves to itself with status `Active`. + +### List an identity's wallets + +```ts +import { getAccounts, getAccountsCount } from '@openzeppelin/identity-sdk'; + +const count = await getAccountsCount(client, { factory, identity }); +const wallets = await getAccounts(client, { factory, identity, start: 0n, end: count }); // readonly Hex[] (envelopes) +``` + +`getAccounts` returns positions `[start, end)`, clamped to the number of wallets. Only active wallets are listed, and never the identity's own address. Envelopes are unbounded bytes, so for identities with many wallets read in pages that fit your provider's `eth_call` gas cap. + +## Link a wallet + +Linking needs two parties: + +1. The **wallet** consents by signing an EIP-712 `LinkAccount` message under the factory's domain. +2. The **identity** submits `linkAccount` to the factory. The factory binds the wallet to `msg.sender`, so the call must come from the identity itself: a user operation from `toIdentity`, or the identity's ERC-734 `execute`. Since the target is the identity's factory, the signer needs **MANAGEMENT**. + +Only identities deployed by the factory can link. The envelope must name an EVM account on the **current** chain; wallets on other chains use the [cross-chain path](#cross-chain-links). + +### Step 1: the wallet signs + +```ts +import { privateKeyToAccount } from 'viem/accounts'; +import { formatEvmV1, getLinkAccountDomain, nonceForAccount, signLinkAccount } from '@openzeppelin/identity-sdk'; + +const wallet = privateKeyToAccount(process.env.WALLET_KEY as `0x${string}`); +const envelope = formatEvmV1({ chainId: baseSepolia.id, address: wallet.address }); + +const domain = await getLinkAccountDomain(client, { factory }); +const nonce = await nonceForAccount(client, { factory, account: envelope }); +const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // one hour; zero always reverts + +const signature = await signLinkAccount(wallet, { + domain, + account: envelope, + identity, // the identity being linked to + nonce, + expiry, +}); +``` + +`signLinkAccount(signer: LocalAccount, params: LinkAccountSignatureParams)` returns the **raw** 65-byte signature. Unlike claim signatures it is not wrapped in `abi.encode(bytes signer, bytes signature)`: the factory takes the signer from the envelope and checks the raw signature with `SignatureChecker.isValidSignatureNow`. + +For other wallets, sign `getLinkAccountTypedData(params)` (or the digest from `getLinkAccountDigest(params)`) with the wallet and pass the raw signature it produces: + +- ERC-1271 smart wallets: the envelope holds the wallet address, and the wallet's `isValidSignature` is called. +- ERC-7913 signers (passkeys, for example): the envelope holds `verifier || key`. The verifier must be trusted by the factory (`isTrustedVerifier` on `iidentityFactoryAbi`). + +Each wallet has one nonce sequence, shared by all encodings of its envelope. Read it right before signing. + +`identity` can be a predicted address from `computeIdentityAddress`: the address is stable through deployment, so a wallet can sign before the identity exists. The link itself can only be submitted after deployment. + +### Step 2: the identity submits + +With the `identityActions()` extension: + +```ts +const hash = await bundler.linkAccount({ + factory, + wallet: envelope, + signature, + nonce, + expiry, +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +With the encoder, for example to batch: + +```ts +import { prepareLinkAccount } from '@openzeppelin/identity-sdk'; + +const call = prepareLinkAccount({ factory, account: envelope, signature, nonce, expiry }); +const hash = await bundler.sendUserOperation({ calls: [call] }); +``` + +### With `identityActions()` + +The encoders call the envelope `account`, matching the factory ABI. The `identityActions()` methods (`linkAccount`, `revokeAccount`, `settlePendingCrossChainLink`) call it **`wallet`** instead, because their parameters also accept every `sendUserOperation` parameter, and `account` there already means the sending smart account. Passing the envelope as `account` to an extension method would be read as the sender. + +| Function | Envelope parameter | +| ------------------------------------------------------------------------------------- | ------------------ | +| `prepareLinkAccount`, `prepareRevokeAccount`, `prepareSettlePendingCrossChainLink` | `account` | +| `bundler.linkAccount`, `bundler.revokeAccount`, `bundler.settlePendingCrossChainLink` | `wallet` | +| Reads (`getIdentity`, `nonceForAccount`, ...) | `account` | + +## Revoke a wallet + +```ts +import { prepareRevokeAccount } from '@openzeppelin/identity-sdk'; + +const hash = await bundler.revokeAccount({ factory, wallet: envelope }); +// or: bundler.sendUserOperation({ calls: [prepareRevokeAccount({ factory, account: envelope })] }) +``` + +The call must come from the identity the wallet is linked to. + +**Revocation is terminal.** A revoked wallet can never be linked again, to this identity or any other. Revoking also reverts for identity types whose policy binds a single wallet (such as a token's `ASSET` identity), since they could not link a replacement. + +## Cross-chain links + +A wallet on another chain cannot prove control with a signature checked on this chain. Instead, it proposes the link on its own chain, and a trusted ERC-7786 gateway delivers the proposal to this factory. The proposal waits until the named identity accepts or declines it. The SDK does not cover the proposing side; it covers reading and settling the proposal. + +```ts +import { formatEvmV1, getPendingCrossChainLink, prepareSettlePendingCrossChainLink } from '@openzeppelin/identity-sdk'; + +const foreign = formatEvmV1({ chainId: 1, address: walletOnMainnet }); + +const pending = await getPendingCrossChainLink(client, { factory, account: foreign }); +// { identity, expiry }, both zero when there is no proposal + +if (pending.identity === identity) { + const now = BigInt(Math.floor(Date.now() / 1000)); + const hash = await bundler.settlePendingCrossChainLink({ + factory, + wallet: foreign, + accept: now <= pending.expiry, // accepting reverts after expiry; declining works any time + }); + await bundler.waitForUserOperationReceipt({ hash }); +} +``` + +The call must come from the identity the proposal names. `prepareSettlePendingCrossChainLink({ factory, account, accept })` is the encoder form. + +## Reference + +| Function | Parameters | Returns | +| ------------------------------------ | ------------------------------------------------------ | ------------------------------------- | +| `getIdentity` | `{ factory, account }` | `Address` | +| `getIdentityIncludingRevoked` | `{ factory, account }` | `{ identity, status: AccountStatus }` | +| `getIdentityWithType` | `{ factory, account }` | `{ identity, identityType: bigint }` | +| `getAccountStatus` | `{ factory, account }` | `AccountStatus` | +| `nonceForAccount` | `{ factory, account }` | `bigint` | +| `getPendingCrossChainLink` | `{ factory, account }` | `{ identity, expiry: bigint }` | +| `getAccounts` | `{ factory, identity, start, end }` | `readonly Hex[]` | +| `getAccountsCount` | `{ factory, identity }` | `bigint` | +| `getLinkAccountDomain` | `{ factory }` | `TypedDataDomain` | +| `signLinkAccount` | `signer, { domain, account, identity, nonce, expiry }` | raw signature `Hex` | +| `prepareLinkAccount` | `{ factory, account, signature, nonce, expiry }` | `Call` | +| `prepareRevokeAccount` | `{ factory, account }` | `Call` | +| `prepareSettlePendingCrossChainLink` | `{ factory, account, accept }` | `Call` | diff --git a/content/identity-sdk/index.mdx b/content/identity-sdk/index.mdx new file mode 100644 index 00000000..9f7ecfef --- /dev/null +++ b/content/identity-sdk/index.mdx @@ -0,0 +1,117 @@ +--- +title: Identity SDK +description: Viem-native TypeScript SDK for the ONCHAINID identity stack +--- + +ONCHAINID gives a person, company, asset or agent an on-chain identity contract that holds keys (ERC-734) and signed claims about it from trusted issuers (ERC-735). In this version each identity is an ERC-7579 modular smart account deployed by an `IdentityFactory`, so it can be driven with ERC-4337 user operations as well as plain transactions. + +`@openzeppelin/identity-sdk` covers what an integrator needs on top of [viem](https://viem.sh): + +- address prediction and deployment through the `IdentityFactory`; +- `toIdentity`, which turns a deployed identity into a viem `SmartAccount` for any ERC-4337 bundler; +- call encoders and read actions for keys, claims, wallet linking, modules and the ERC-734 execution queue; +- EIP-712 signing helpers for claims and `LinkAccount` messages; +- passkey keys through ERC-7913 verifiers; +- the vendored ONCHAINID ABIs. + + + The SDK is in early development. Its public API is unstable through the ongoing ONCHAINID audit cycle (0.x semver). + + +## Installation + +```bash +npm install @openzeppelin/identity-sdk viem +``` + +Requires Node.js 22 or later. + +`toIdentity` is built on [`@openzeppelin-accounts/sdk`](/accounts/sdk), declared as an optional peer dependency. That package is not on npm yet; until it is, consumers who use `toIdentity` need to provide it themselves. Only `toIdentity` uses it; everything else (encoders, reads, signing, addressing, ABIs) depends on viem alone. The package root re-exports `toIdentity`, so `@openzeppelin-accounts/sdk` must currently be resolvable for an `import ... from '@openzeppelin/identity-sdk'` to load. + +## Quickstart + +Resolve a wallet's identity, then add a key to it through a bundler: + +```ts +import { createPublicClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createBundlerClient } from 'viem/account-abstraction'; +import { baseSepolia } from 'viem/chains'; +import { + formatEvmV1, + getIdentity, + identityActions, + KeyPurposes, + KeyTypes, + toIdentity, +} from '@openzeppelin/identity-sdk'; + +const client = createPublicClient({ chain: baseSepolia, transport: http() }); +const owner = privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`); + +// Resolve the owner's wallet to its identity. +const factory = '0x...'; // IdentityFactory address +const identity = await getIdentity(client, { + factory, + account: formatEvmV1({ chainId: baseSepolia.id, address: owner.address }), +}); + +// Drive the identity as an ERC-4337 account and add an ACTION key. +const account = await toIdentity(client, { address: identity, signer: owner }); +const bundler = createBundlerClient({ account, client, transport: http(process.env.BUNDLER_URL) }).extend( + identityActions(), +); +const hash = await bundler.addKey({ + identity, + signerData: '0x...', // 20-byte address of the new key + purpose: KeyPurposes.ACTION, + keyType: KeyTypes.ECDSA, +}); +await bundler.waitForUserOperationReceipt({ hash }); +``` + +## Next steps + + + + + + + + + + + + + + + +The source code is at [github.com/OpenZeppelin/identity-sdk](https://github.com/OpenZeppelin/identity-sdk). diff --git a/src/navigation/ethereum-evm.json b/src/navigation/ethereum-evm.json index e2c8489b..94553862 100644 --- a/src/navigation/ethereum-evm.json +++ b/src/navigation/ethereum-evm.json @@ -802,6 +802,78 @@ } ] }, + { + "type": "folder", + "name": "Identity SDK", + "index": { + "type": "page", + "name": "Overview", + "url": "/identity-sdk" + }, + "children": [ + { + "type": "page", + "name": "Concepts", + "url": "/identity-sdk/concepts" + }, + { + "type": "folder", + "name": "Guides", + "children": [ + { + "type": "page", + "name": "Deploy an Identity", + "url": "/identity-sdk/guides/deploy-identity" + }, + { + "type": "page", + "name": "Account Abstraction", + "url": "/identity-sdk/guides/account-abstraction" + }, + { + "type": "page", + "name": "Keys", + "url": "/identity-sdk/guides/keys" + }, + { + "type": "page", + "name": "Passkeys", + "url": "/identity-sdk/guides/passkeys" + }, + { + "type": "page", + "name": "Claims", + "url": "/identity-sdk/guides/claims" + }, + { + "type": "page", + "name": "Wallet Linking", + "url": "/identity-sdk/guides/wallet-linking" + }, + { + "type": "page", + "name": "Modules", + "url": "/identity-sdk/guides/modules" + }, + { + "type": "page", + "name": "Execution Queue", + "url": "/identity-sdk/guides/execution" + } + ] + }, + { + "type": "page", + "name": "Gas", + "url": "/identity-sdk/gas" + }, + { + "type": "page", + "name": "ABIs", + "url": "/identity-sdk/abi" + } + ] + }, { "type": "folder", "name": "Upgrades Plugins",