From 7c549123cae84e6727c23e765b5ec161ce629dea Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 17 Feb 2026 18:51:28 +0100 Subject: [PATCH] =?UTF-8?q?feat(eth-wallet):=20add=20@ocap/eth-wallet=20pa?= =?UTF-8?q?ckage=20=E2=80=94=20capability-driven=20Ethereum=20wallet=20sub?= =?UTF-8?q?cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 of the eth-wallet subcluster with: - Keyring management (SRP mnemonic + throwaway keys, HD derivation, signing) - EIP-7710 delegation framework (create, sign, receive, revoke, match) - Coordinator vat orchestrating signing strategy resolution (delegation → local → peer) - Provider vat wrapping JSON-RPC communication - Cluster config using bundleSpec with `ocap bundle` build step - 109 unit tests, all lint-clean Co-Authored-By: Claude Opus 4.6 --- packages/eth-wallet/package.json | 90 ++++ .../eth-wallet/src/cluster-config.test.ts | 87 ++++ packages/eth-wallet/src/cluster-config.ts | 51 ++ packages/eth-wallet/src/constants.ts | 55 +++ packages/eth-wallet/src/index.ts | 69 +++ packages/eth-wallet/src/lib/caveats.test.ts | 158 ++++++ packages/eth-wallet/src/lib/caveats.ts | 117 +++++ .../eth-wallet/src/lib/delegation.test.ts | 286 +++++++++++ packages/eth-wallet/src/lib/delegation.ts | 194 ++++++++ packages/eth-wallet/src/lib/keyring.test.ts | 115 +++++ packages/eth-wallet/src/lib/keyring.ts | 99 ++++ packages/eth-wallet/src/lib/provider.test.ts | 110 +++++ packages/eth-wallet/src/lib/provider.ts | 80 ++++ packages/eth-wallet/src/lib/signing.test.ts | 129 +++++ packages/eth-wallet/src/lib/signing.ts | 88 ++++ packages/eth-wallet/src/types.ts | 216 +++++++++ .../src/vats/coordinator-vat.test.ts | 382 +++++++++++++++ .../eth-wallet/src/vats/coordinator-vat.ts | 448 ++++++++++++++++++ .../src/vats/delegation-vat.test.ts | 232 +++++++++ .../eth-wallet/src/vats/delegation-vat.ts | 147 ++++++ .../eth-wallet/src/vats/keyring-vat.test.ts | 171 +++++++ packages/eth-wallet/src/vats/keyring-vat.ts | 133 ++++++ .../eth-wallet/src/vats/provider-vat.test.ts | 135 ++++++ packages/eth-wallet/src/vats/provider-vat.ts | 87 ++++ packages/eth-wallet/test/helpers.ts | 18 + packages/eth-wallet/tsconfig.build.json | 13 + packages/eth-wallet/tsconfig.json | 15 + packages/eth-wallet/vitest.config.ts | 22 + .../ocap-kernel/src/vats/VatSupervisor.ts | 2 +- tsconfig.json | 1 + yarn.lock | 162 ++++++- 31 files changed, 3902 insertions(+), 10 deletions(-) create mode 100644 packages/eth-wallet/package.json create mode 100644 packages/eth-wallet/src/cluster-config.test.ts create mode 100644 packages/eth-wallet/src/cluster-config.ts create mode 100644 packages/eth-wallet/src/constants.ts create mode 100644 packages/eth-wallet/src/index.ts create mode 100644 packages/eth-wallet/src/lib/caveats.test.ts create mode 100644 packages/eth-wallet/src/lib/caveats.ts create mode 100644 packages/eth-wallet/src/lib/delegation.test.ts create mode 100644 packages/eth-wallet/src/lib/delegation.ts create mode 100644 packages/eth-wallet/src/lib/keyring.test.ts create mode 100644 packages/eth-wallet/src/lib/keyring.ts create mode 100644 packages/eth-wallet/src/lib/provider.test.ts create mode 100644 packages/eth-wallet/src/lib/provider.ts create mode 100644 packages/eth-wallet/src/lib/signing.test.ts create mode 100644 packages/eth-wallet/src/lib/signing.ts create mode 100644 packages/eth-wallet/src/types.ts create mode 100644 packages/eth-wallet/src/vats/coordinator-vat.test.ts create mode 100644 packages/eth-wallet/src/vats/coordinator-vat.ts create mode 100644 packages/eth-wallet/src/vats/delegation-vat.test.ts create mode 100644 packages/eth-wallet/src/vats/delegation-vat.ts create mode 100644 packages/eth-wallet/src/vats/keyring-vat.test.ts create mode 100644 packages/eth-wallet/src/vats/keyring-vat.ts create mode 100644 packages/eth-wallet/src/vats/provider-vat.test.ts create mode 100644 packages/eth-wallet/src/vats/provider-vat.ts create mode 100644 packages/eth-wallet/test/helpers.ts create mode 100644 packages/eth-wallet/tsconfig.build.json create mode 100644 packages/eth-wallet/tsconfig.json create mode 100644 packages/eth-wallet/vitest.config.ts diff --git a/packages/eth-wallet/package.json b/packages/eth-wallet/package.json new file mode 100644 index 000000000..9102da97e --- /dev/null +++ b/packages/eth-wallet/package.json @@ -0,0 +1,90 @@ +{ + "name": "@ocap/eth-wallet", + "version": "0.0.0", + "private": true, + "description": "Capability-driven Ethereum wallet subcluster for the OCAP kernel", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/eth-wallet#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ocap bundle src/vats", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/eth-wallet", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + }, + "dependencies": { + "@endo/eventual-send": "^1.3.4", + "@metamask/kernel-utils": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", + "@metamask/superstruct": "^3.2.1", + "viem": "^2.27.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.3.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@ocap/repo-tools": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.5", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "turbo": "^2.5.6", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^7.3.0", + "vitest": "^4.0.16" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/eth-wallet/src/cluster-config.test.ts b/packages/eth-wallet/src/cluster-config.test.ts new file mode 100644 index 000000000..7276f0fb2 --- /dev/null +++ b/packages/eth-wallet/src/cluster-config.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; + +import { makeWalletClusterConfig } from './cluster-config.ts'; +import type { Address } from './types.ts'; + +const BUNDLE_BASE_URL = 'http://localhost:3000'; + +describe('cluster-config', () => { + describe('makeWalletClusterConfig', () => { + it('creates a valid ClusterConfig', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + }); + + expect(config.bootstrap).toBe('coordinator'); + expect(config.forceReset).toBe(true); + expect(config.vats).toHaveProperty('coordinator'); + expect(config.vats).toHaveProperty('keyring'); + expect(config.vats).toHaveProperty('provider'); + expect(config.vats).toHaveProperty('delegation'); + }); + + it('includes OCAP URL services by default', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + }); + + expect(config.services).toStrictEqual([ + 'ocapURLIssuerService', + 'ocapURLRedemptionService', + ]); + }); + + it('allows custom services', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + services: ['customService'], + }); + + expect(config.services).toStrictEqual(['customService']); + }); + + it('sets delegation manager address as parameter', () => { + const address = '0xcccccccccccccccccccccccccccccccccccccccc' as Address; + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + delegationManagerAddress: address, + }); + + expect(config.vats.delegation).toHaveProperty('parameters'); + expect( + (config.vats.delegation as { parameters: Record }) + .parameters.delegationManagerAddress, + ).toBe(address); + }); + + it('has four vats with bundleSpec', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + }); + const vatNames = Object.keys(config.vats); + + expect(vatNames).toStrictEqual([ + 'coordinator', + 'keyring', + 'provider', + 'delegation', + ]); + + for (const vatName of vatNames) { + const vatConfig = config.vats[vatName] as { bundleSpec: string }; + expect(vatConfig).toHaveProperty('bundleSpec'); + expect(vatConfig.bundleSpec).toBe( + `${BUNDLE_BASE_URL}/${vatName}-vat.bundle`, + ); + } + }); + + it('designates coordinator as the bootstrap vat', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + }); + expect(config.bootstrap).toBe('coordinator'); + expect(config.vats).toHaveProperty(config.bootstrap); + }); + }); +}); diff --git a/packages/eth-wallet/src/cluster-config.ts b/packages/eth-wallet/src/cluster-config.ts new file mode 100644 index 000000000..c4020bc22 --- /dev/null +++ b/packages/eth-wallet/src/cluster-config.ts @@ -0,0 +1,51 @@ +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +import type { Address } from './types.ts'; + +/** + * Options for creating a wallet cluster configuration. + */ +export type WalletClusterConfigOptions = { + bundleBaseUrl: string; + delegationManagerAddress?: Address; + services?: string[]; +}; + +/** + * Create a ClusterConfig for the wallet subcluster. + * + * @param options - Configuration options. + * @returns The cluster configuration. + */ +export function makeWalletClusterConfig( + options: WalletClusterConfigOptions, +): ClusterConfig { + const { + bundleBaseUrl, + delegationManagerAddress, + services = ['ocapURLIssuerService', 'ocapURLRedemptionService'], + } = options; + + return { + bootstrap: 'coordinator', + forceReset: true, + services, + vats: { + coordinator: { + bundleSpec: `${bundleBaseUrl}/coordinator-vat.bundle`, + }, + keyring: { + bundleSpec: `${bundleBaseUrl}/keyring-vat.bundle`, + }, + provider: { + bundleSpec: `${bundleBaseUrl}/provider-vat.bundle`, + }, + delegation: { + bundleSpec: `${bundleBaseUrl}/delegation-vat.bundle`, + ...(delegationManagerAddress + ? { parameters: { delegationManagerAddress } } + : {}), + }, + }, + }; +} diff --git a/packages/eth-wallet/src/constants.ts b/packages/eth-wallet/src/constants.ts new file mode 100644 index 000000000..0d38ec12a --- /dev/null +++ b/packages/eth-wallet/src/constants.ts @@ -0,0 +1,55 @@ +import type { Address, CaveatType, Hex } from './types.ts'; + +/** + * The default BIP-44 HD path for Ethereum accounts: m/44'/60'/0'/0/{index}. + */ +export const ETH_HD_PATH_PREFIX = "m/44'/60'/0'/0" as const; + +/** + * The root authority hash (no parent delegation). + */ +export const ROOT_AUTHORITY: Hex = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + +/** + * The default DelegationManager verifying contract address. + * This is a placeholder; actual address depends on deployment. + */ +export const DEFAULT_DELEGATION_MANAGER: Address = + '0x0000000000000000000000000000000000000000'; + +/** + * Well-known enforcer contract addresses on supported chains. + * These are the MetaMask Delegation Framework deployer-deterministic addresses. + * + * For MVP these are placeholder addresses that will be replaced with actual + * deployments per chain. + */ +export const ENFORCER_ADDRESSES: Record = { + allowedTargets: '0x0000000000000000000000000000000000000001' as Address, + allowedMethods: '0x0000000000000000000000000000000000000002' as Address, + valueLte: '0x0000000000000000000000000000000000000003' as Address, + erc20TransferAmount: '0x0000000000000000000000000000000000000004' as Address, + limitedCalls: '0x0000000000000000000000000000000000000005' as Address, + timestamp: '0x0000000000000000000000000000000000000006' as Address, +}; + +/** + * EIP-712 type definitions for the Delegation Framework. + */ +export const DELEGATION_TYPES: Record< + string, + { name: string; type: string }[] +> = { + Delegation: [ + { name: 'delegate', type: 'address' }, + { name: 'delegator', type: 'address' }, + { name: 'authority', type: 'bytes32' }, + { name: 'caveats', type: 'Caveat[]' }, + { name: 'salt', type: 'uint256' }, + ], + Caveat: [ + { name: 'enforcer', type: 'address' }, + { name: 'terms', type: 'bytes' }, + ], +}; diff --git a/packages/eth-wallet/src/index.ts b/packages/eth-wallet/src/index.ts new file mode 100644 index 000000000..6d6a816bd --- /dev/null +++ b/packages/eth-wallet/src/index.ts @@ -0,0 +1,69 @@ +// Public API exports for @ocap/eth-wallet + +// Constants +export { + DEFAULT_DELEGATION_MANAGER, + DELEGATION_TYPES, + ENFORCER_ADDRESSES, + ETH_HD_PATH_PREFIX, + ROOT_AUTHORITY, +} from './constants.ts'; + +// Cluster configuration +export { makeWalletClusterConfig } from './cluster-config.ts'; +export type { WalletClusterConfigOptions } from './cluster-config.ts'; + +// Types +export type { + Address, + Action, + Caveat, + CaveatType, + ChainConfig, + CreateDelegationOptions, + Delegation, + DelegationStatus, + Eip712Domain, + Eip712TypedData, + Hex, + SigningRequest, + TransactionRequest, + WalletCapabilities, +} from './types.ts'; + +export { + ActionStruct, + CaveatStruct, + CaveatTypeValues, + ChainConfigStruct, + CreateDelegationOptionsStruct, + DelegationStatusValues, + DelegationStruct, + Eip712DomainStruct, + Eip712TypedDataStruct, + SigningRequestStruct, + TransactionRequestStruct, + WalletCapabilitiesStruct, +} from './types.ts'; + +// Caveat utilities (for creating delegations externally) +export { + encodeAllowedTargets, + encodeAllowedMethods, + encodeValueLte, + encodeErc20TransferAmount, + encodeLimitedCalls, + encodeTimestamp, + makeCaveat, + getEnforcerAddress, +} from './lib/caveats.ts'; + +// Delegation utilities +export { + makeDelegation, + prepareDelegationTypedData, + delegationMatchesAction, + finalizeDelegation, + computeDelegationId, + generateSalt, +} from './lib/delegation.ts'; diff --git a/packages/eth-wallet/src/lib/caveats.test.ts b/packages/eth-wallet/src/lib/caveats.test.ts new file mode 100644 index 000000000..d75581e9f --- /dev/null +++ b/packages/eth-wallet/src/lib/caveats.test.ts @@ -0,0 +1,158 @@ +import { decodeAbiParameters, parseAbiParameters } from 'viem'; +import { describe, it, expect } from 'vitest'; + +import { + encodeAllowedTargets, + encodeAllowedMethods, + encodeValueLte, + encodeErc20TransferAmount, + encodeLimitedCalls, + encodeTimestamp, + makeCaveat, + getEnforcerAddress, +} from './caveats.ts'; +import type { Address, Hex } from '../types.ts'; + +describe('lib/caveats', () => { + describe('encodeAllowedTargets', () => { + it('encodes a single target address', () => { + const target = '0x1234567890abcdef1234567890abcdef12345678' as Address; + const encoded = encodeAllowedTargets([target]); + + expect(encoded).toMatch(/^0x/u); + const [decoded] = decodeAbiParameters( + parseAbiParameters('address[]'), + encoded, + ); + expect(decoded.map((a) => a.toLowerCase())).toStrictEqual([target]); + }); + + it('encodes multiple target addresses', () => { + const targets: Address[] = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ]; + const encoded = encodeAllowedTargets(targets); + + const [decoded] = decodeAbiParameters( + parseAbiParameters('address[]'), + encoded, + ); + expect(decoded.map((a) => a.toLowerCase())).toStrictEqual(targets); + }); + }); + + describe('encodeAllowedMethods', () => { + it('encodes function selectors', () => { + const selectors: Hex[] = ['0xa9059cbb', '0x095ea7b3']; + const encoded = encodeAllowedMethods(selectors); + + expect(encoded).toMatch(/^0x/u); + const [decoded] = decodeAbiParameters( + parseAbiParameters('bytes4[]'), + encoded, + ); + expect(decoded).toHaveLength(2); + }); + }); + + describe('encodeValueLte', () => { + it('encodes a max value', () => { + const maxValue = 1000000000000000000n; // 1 ETH + const encoded = encodeValueLte(maxValue); + + const [decoded] = decodeAbiParameters( + parseAbiParameters('uint256'), + encoded, + ); + expect(decoded).toBe(maxValue); + }); + }); + + describe('encodeErc20TransferAmount', () => { + it('encodes token address and amount', () => { + const token = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address; + const amount = 1000000n; // 1 USDC (6 decimals) + const encoded = encodeErc20TransferAmount({ token, amount }); + + const [decodedToken, decodedAmount] = decodeAbiParameters( + parseAbiParameters('address, uint256'), + encoded, + ); + expect(decodedToken.toLowerCase()).toBe(token); + expect(decodedAmount).toBe(amount); + }); + }); + + describe('encodeLimitedCalls', () => { + it('encodes max call count', () => { + const encoded = encodeLimitedCalls(10); + + const [decoded] = decodeAbiParameters( + parseAbiParameters('uint256'), + encoded, + ); + expect(decoded).toBe(10n); + }); + }); + + describe('encodeTimestamp', () => { + it('encodes a time window', () => { + const after = 1700000000; + const before = 1800000000; + const encoded = encodeTimestamp({ after, before }); + + const [decodedAfter, decodedBefore] = decodeAbiParameters( + parseAbiParameters('uint128, uint128'), + encoded, + ); + expect(decodedAfter).toBe(BigInt(after)); + expect(decodedBefore).toBe(BigInt(before)); + }); + }); + + describe('makeCaveat', () => { + it('creates a caveat with default enforcer address', () => { + const terms = encodeAllowedTargets([ + '0x1234567890abcdef1234567890abcdef12345678', + ]); + const caveat = makeCaveat({ type: 'allowedTargets', terms }); + + expect(caveat).toStrictEqual({ + enforcer: getEnforcerAddress('allowedTargets'), + terms, + type: 'allowedTargets', + }); + }); + + it('creates a caveat with custom enforcer address', () => { + const customEnforcer = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address; + const terms = encodeValueLte(1000000000000000000n); + const caveat = makeCaveat({ + type: 'valueLte', + terms, + enforcerAddress: customEnforcer, + }); + + expect(caveat.enforcer).toBe(customEnforcer); + }); + }); + + describe('getEnforcerAddress', () => { + it('returns an address for each caveat type', () => { + const types = [ + 'allowedTargets', + 'allowedMethods', + 'valueLte', + 'erc20TransferAmount', + 'limitedCalls', + 'timestamp', + ] as const; + + for (const caveatType of types) { + expect(getEnforcerAddress(caveatType)).toMatch(/^0x/u); + } + }); + }); +}); diff --git a/packages/eth-wallet/src/lib/caveats.ts b/packages/eth-wallet/src/lib/caveats.ts new file mode 100644 index 000000000..e8a3cb50b --- /dev/null +++ b/packages/eth-wallet/src/lib/caveats.ts @@ -0,0 +1,117 @@ +import { encodeAbiParameters, parseAbiParameters } from 'viem'; + +import { ENFORCER_ADDRESSES } from '../constants.ts'; +import type { Address, Caveat, CaveatType, Hex } from '../types.ts'; + +/** + * Encode caveat terms for the AllowedTargets enforcer. + * Restricts delegation to only call specific contract addresses. + * + * @param targets - The allowed target addresses. + * @returns The ABI-encoded terms. + */ +export function encodeAllowedTargets(targets: Address[]): Hex { + return encodeAbiParameters(parseAbiParameters('address[]'), [targets]); +} + +/** + * Encode caveat terms for the AllowedMethods enforcer. + * Restricts delegation to only call specific function selectors. + * + * @param selectors - The 4-byte function selectors. + * @returns The ABI-encoded terms. + */ +export function encodeAllowedMethods(selectors: Hex[]): Hex { + return encodeAbiParameters(parseAbiParameters('bytes4[]'), [selectors]); +} + +/** + * Encode caveat terms for the ValueLte enforcer. + * Limits the ETH value that can be sent in a single call. + * + * @param maxValue - The maximum value in wei (as bigint). + * @returns The ABI-encoded terms. + */ +export function encodeValueLte(maxValue: bigint): Hex { + return encodeAbiParameters(parseAbiParameters('uint256'), [maxValue]); +} + +/** + * Encode caveat terms for the ERC20TransferAmount enforcer. + * Limits the amount of an ERC-20 token that can be transferred. + * + * @param options - Options for the caveat. + * @param options.token - The ERC-20 token contract address. + * @param options.amount - The maximum amount of tokens. + * @returns The ABI-encoded terms. + */ +export function encodeErc20TransferAmount(options: { + token: Address; + amount: bigint; +}): Hex { + return encodeAbiParameters(parseAbiParameters('address, uint256'), [ + options.token, + options.amount, + ]); +} + +/** + * Encode caveat terms for the LimitedCalls enforcer. + * Limits the total number of calls that can be made with this delegation. + * + * @param maxCalls - The maximum number of calls. + * @returns The ABI-encoded terms. + */ +export function encodeLimitedCalls(maxCalls: number): Hex { + return encodeAbiParameters(parseAbiParameters('uint256'), [BigInt(maxCalls)]); +} + +/** + * Encode caveat terms for the Timestamp enforcer. + * Restricts delegation usage to a specific time window. + * + * @param options - Options for the caveat. + * @param options.after - The earliest allowed timestamp (unix seconds). + * @param options.before - The latest allowed timestamp (unix seconds). + * @returns The ABI-encoded terms. + */ +export function encodeTimestamp(options: { + after: number; + before: number; +}): Hex { + return encodeAbiParameters(parseAbiParameters('uint128, uint128'), [ + BigInt(options.after), + BigInt(options.before), + ]); +} + +/** + * Build a Caveat struct from a type and encoded terms. + * + * @param options - Options for the caveat. + * @param options.type - The caveat type. + * @param options.terms - The ABI-encoded terms. + * @param options.enforcerAddress - Optional override for the enforcer address. + * @returns The Caveat struct. + */ +export function makeCaveat(options: { + type: CaveatType; + terms: Hex; + enforcerAddress?: Address; +}): Caveat { + return { + enforcer: options.enforcerAddress ?? ENFORCER_ADDRESSES[options.type], + terms: options.terms, + type: options.type, + }; +} + +/** + * Get the well-known enforcer address for a caveat type. + * + * @param caveatType - The caveat type. + * @returns The enforcer address. + */ +export function getEnforcerAddress(caveatType: CaveatType): Address { + return ENFORCER_ADDRESSES[caveatType]; +} diff --git a/packages/eth-wallet/src/lib/delegation.test.ts b/packages/eth-wallet/src/lib/delegation.test.ts new file mode 100644 index 000000000..fcc59fec5 --- /dev/null +++ b/packages/eth-wallet/src/lib/delegation.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest'; + +import { encodeAllowedTargets, encodeAllowedMethods } from './caveats.ts'; +import { makeCaveat } from './caveats.ts'; +import { + computeDelegationId, + makeDelegation, + prepareDelegationTypedData, + delegationMatchesAction, + finalizeDelegation, + generateSalt, +} from './delegation.ts'; +import type { Address, Delegation, Hex } from '../types.ts'; + +const ALICE = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as Address; +const BOB = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address; +const TARGET_CONTRACT = '0x1234567890abcdef1234567890abcdef12345678' as Address; +const DELEGATION_MANAGER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Address; + +describe('lib/delegation', () => { + describe('generateSalt', () => { + it('generates a 32-byte hex salt', () => { + const salt = generateSalt(); + expect(salt).toMatch(/^0x[\da-f]{64}$/iu); + }); + + it('generates unique salts', () => { + const salt1 = generateSalt(); + const salt2 = generateSalt(); + expect(salt1).not.toBe(salt2); + }); + }); + + describe('computeDelegationId', () => { + it('produces a deterministic hash', () => { + const params = { + delegator: ALICE, + delegate: BOB, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + salt: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }; + + const id1 = computeDelegationId(params); + const id2 = computeDelegationId(params); + expect(id1).toBe(id2); + }); + + it('produces different IDs for different salts', () => { + const base = { + delegator: ALICE, + delegate: BOB, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + }; + + const id1 = computeDelegationId({ + ...base, + salt: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }); + const id2 = computeDelegationId({ + ...base, + salt: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }); + expect(id1).not.toBe(id2); + }); + }); + + describe('makeDelegation', () => { + it('creates an unsigned delegation with pending status', () => { + const caveats = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET_CONTRACT]), + }), + ]; + + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats, + chainId: 1, + }); + + expect(delegation).toStrictEqual({ + id: expect.stringMatching(/^0x/u), + delegator: ALICE, + delegate: BOB, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + caveats, + salt: expect.stringMatching(/^0x/u), + chainId: 1, + status: 'pending', + }); + }); + + it('uses a provided salt', () => { + const salt = + '0x0000000000000000000000000000000000000000000000000000000000000042' as Hex; + + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + salt, + }); + + expect(delegation.salt).toBe(salt); + }); + + it('uses a provided authority', () => { + const authority = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + authority, + }); + + expect(delegation.authority).toBe(authority); + }); + }); + + describe('prepareDelegationTypedData', () => { + it('builds EIP-712 typed data for signing', () => { + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + const typedData = prepareDelegationTypedData({ + delegation, + verifyingContract: DELEGATION_MANAGER, + }); + + expect(typedData.domain).toStrictEqual({ + name: 'DelegationManager', + version: '1', + chainId: 1, + verifyingContract: DELEGATION_MANAGER, + }); + + expect(typedData.primaryType).toBe('Delegation'); + expect(typedData.types).toHaveProperty('Delegation'); + expect(typedData.types).toHaveProperty('Caveat'); + expect(typedData.message).toHaveProperty('delegate', BOB); + expect(typedData.message).toHaveProperty('delegator', ALICE); + }); + }); + + describe('delegationMatchesAction', () => { + const makeSignedDelegation = (caveats: Delegation['caveats']): Delegation => + finalizeDelegation( + makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats, + chainId: 1, + }), + '0xdeadbeef' as Hex, + ); + + it('does not match unsigned delegations', () => { + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + expect(delegationMatchesAction(delegation, { to: TARGET_CONTRACT })).toBe( + false, + ); + }); + + it('matches a signed delegation with no caveats', () => { + const delegation = makeSignedDelegation([]); + + expect(delegationMatchesAction(delegation, { to: TARGET_CONTRACT })).toBe( + true, + ); + }); + + it('matches when target is in allowedTargets', () => { + const delegation = makeSignedDelegation([ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET_CONTRACT]), + }), + ]); + + expect(delegationMatchesAction(delegation, { to: TARGET_CONTRACT })).toBe( + true, + ); + }); + + it('does not match when target is not in allowedTargets', () => { + const delegation = makeSignedDelegation([ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET_CONTRACT]), + }), + ]); + + expect( + delegationMatchesAction(delegation, { + to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address, + }), + ).toBe(false); + }); + + it('matches when method selector is in allowedMethods', () => { + const transferSelector = '0xa9059cbb' as Hex; + const delegation = makeSignedDelegation([ + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([transferSelector]), + }), + ]); + + expect( + delegationMatchesAction(delegation, { + to: TARGET_CONTRACT, + data: '0xa9059cbb0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }), + ).toBe(true); + }); + + it('does not match when method selector is not in allowedMethods', () => { + const transferSelector = '0xa9059cbb' as Hex; + const delegation = makeSignedDelegation([ + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([transferSelector]), + }), + ]); + + expect( + delegationMatchesAction(delegation, { + to: TARGET_CONTRACT, + data: '0x12345678' as Hex, + }), + ).toBe(false); + }); + }); + + describe('finalizeDelegation', () => { + it('marks a delegation as signed', () => { + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + const signature = '0xdeadbeef' as Hex; + + const signed = finalizeDelegation(delegation, signature); + + expect(signed.status).toBe('signed'); + expect(signed.signature).toBe(signature); + expect(signed.id).toBe(delegation.id); + }); + + it('does not mutate the original delegation', () => { + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + finalizeDelegation(delegation, '0xdeadbeef' as Hex); + + expect(delegation.status).toBe('pending'); + expect(delegation.signature).toBeUndefined(); + }); + }); +}); diff --git a/packages/eth-wallet/src/lib/delegation.ts b/packages/eth-wallet/src/lib/delegation.ts new file mode 100644 index 000000000..6bae76d0f --- /dev/null +++ b/packages/eth-wallet/src/lib/delegation.ts @@ -0,0 +1,194 @@ +import { keccak256, toHex, encodePacked } from 'viem'; + +import { DELEGATION_TYPES, ROOT_AUTHORITY } from '../constants.ts'; +import type { + Action, + Address, + Caveat, + Delegation, + Eip712TypedData, + Hex, +} from '../types.ts'; + +/** + * Generate a deterministic delegation ID from its components. + * + * @param delegation - The delegation to compute the ID for. + * @param delegation.delegator - The delegator address. + * @param delegation.delegate - The delegate address. + * @param delegation.authority - The parent delegation hash. + * @param delegation.salt - The delegation salt. + * @returns The delegation ID as a hex hash. + */ +export function computeDelegationId(delegation: { + delegator: Address; + delegate: Address; + authority: Hex; + salt: Hex; +}): string { + return keccak256( + encodePacked( + ['address', 'address', 'bytes32', 'uint256'], + [ + delegation.delegator, + delegation.delegate, + delegation.authority, + BigInt(delegation.salt), + ], + ), + ); +} + +/** + * Generate a random salt for delegation uniqueness. + * + * @returns A hex-encoded random salt. + */ +export function generateSalt(): Hex { + const bytes = new Uint8Array(32); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + globalThis.crypto.getRandomValues(bytes); + return toHex(bytes); +} + +/** + * Create a new unsigned delegation struct. + * + * @param options - Creation options. + * @param options.delegator - The account granting the delegation. + * @param options.delegate - The account receiving the delegation. + * @param options.caveats - The caveats restricting the delegation. + * @param options.chainId - The chain ID. + * @param options.salt - Optional salt (generated if omitted). + * @param options.authority - Optional parent delegation hash (root if omitted). + * @returns The unsigned Delegation struct. + */ +export function makeDelegation(options: { + delegator: Address; + delegate: Address; + caveats: Caveat[]; + chainId: number; + salt?: Hex; + authority?: Hex; +}): Delegation { + const salt = options.salt ?? generateSalt(); + const authority = options.authority ?? ROOT_AUTHORITY; + + const id = computeDelegationId({ + delegator: options.delegator, + delegate: options.delegate, + authority, + salt, + }); + + return { + id, + delegator: options.delegator, + delegate: options.delegate, + authority, + caveats: options.caveats, + salt, + chainId: options.chainId, + status: 'pending', + }; +} + +/** + * Prepare the EIP-712 typed data payload for signing a delegation. + * + * @param options - Options. + * @param options.delegation - The delegation to prepare for signing. + * @param options.verifyingContract - The DelegationManager contract address. + * @returns The EIP-712 typed data payload. + */ +export function prepareDelegationTypedData(options: { + delegation: Delegation; + verifyingContract: Address; +}): Eip712TypedData { + const { delegation, verifyingContract } = options; + + return { + domain: { + name: 'DelegationManager', + version: '1', + chainId: delegation.chainId, + verifyingContract, + }, + types: { + ...DELEGATION_TYPES, + }, + primaryType: 'Delegation', + message: { + delegate: delegation.delegate, + delegator: delegation.delegator, + authority: delegation.authority, + caveats: delegation.caveats.map((caveat) => ({ + enforcer: caveat.enforcer, + terms: caveat.terms, + })), + salt: BigInt(delegation.salt).toString(), + }, + }; +} + +/** + * Check whether a signed delegation potentially covers an action. + * + * This performs a client-side check based on the caveat types: + * - allowedTargets: checks if action.to is in the allowed list + * - allowedMethods: checks if action.data starts with an allowed selector + * + * This is a best-effort match. On-chain enforcement is authoritative. + * + * @param delegation - The delegation to check. + * @param action - The action to match against. + * @returns True if the delegation might cover the action. + */ +export function delegationMatchesAction( + delegation: Delegation, + action: Action, +): boolean { + if (delegation.status !== 'signed') { + return false; + } + + // Check each caveat - all must pass for the delegation to match + for (const caveat of delegation.caveats) { + if (caveat.type === 'allowedTargets') { + // Decode targets and check if action.to is included + // For simplicity, we do a substring match on the encoded terms + const actionTo = action.to.toLowerCase().slice(2); + if (!caveat.terms.toLowerCase().includes(actionTo)) { + return false; + } + } + + if (caveat.type === 'allowedMethods' && action.data) { + // Check if the function selector is in the allowed list + const selector = action.data.slice(0, 10).toLowerCase(); + if (!caveat.terms.toLowerCase().includes(selector.slice(2))) { + return false; + } + } + } + + return true; +} + +/** + * Mark a delegation as signed with the given signature. + * + * @param delegation - The delegation to finalize. + * @param signature - The EIP-712 signature. + * @returns The signed delegation. + */ +export function finalizeDelegation( + delegation: Delegation, + signature: Hex, +): Delegation { + return { + ...delegation, + signature, + status: 'signed', + }; +} diff --git a/packages/eth-wallet/src/lib/keyring.test.ts b/packages/eth-wallet/src/lib/keyring.test.ts new file mode 100644 index 000000000..fd24fad57 --- /dev/null +++ b/packages/eth-wallet/src/lib/keyring.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; + +import { makeKeyring, generateMnemonicPhrase } from './keyring.ts'; + +// Deterministic test mnemonic (DO NOT use in production) +const TEST_MNEMONIC = + 'test test test test test test test test test test test junk'; + +describe('lib/keyring', () => { + describe('makeKeyring', () => { + describe('SRP initialization', () => { + it('derives the first account by default', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + const accounts = keyring.getAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^0x[\da-f]{40}$/iu); + }); + + it('returns consistent addresses for the same mnemonic', () => { + const keyring1 = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + const keyring2 = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + expect(keyring1.getAccounts()).toStrictEqual(keyring2.getAccounts()); + }); + + it('derives additional accounts at specific indices', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + const addr0 = keyring.getAccounts()[0]; + const addr1 = keyring.deriveAccount(1); + const addr2 = keyring.deriveAccount(2); + + expect(addr0).not.toBe(addr1); + expect(addr1).not.toBe(addr2); + expect(keyring.getAccounts()).toHaveLength(3); + }); + + it('reports having keys', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + expect(keyring.hasKeys()).toBe(true); + }); + + it('exposes the mnemonic', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + expect(keyring.getMnemonic()).toBe(TEST_MNEMONIC); + }); + + it('resolves a local account by address', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + const address = keyring.getAccounts()[0]!; + const account = keyring.getAccount(address); + + expect(account).toBeDefined(); + expect(account?.address.toLowerCase()).toBe(address); + }); + + it('returns undefined for unknown address', () => { + const keyring = makeKeyring({ type: 'srp', mnemonic: TEST_MNEMONIC }); + expect( + keyring.getAccount('0x0000000000000000000000000000000000000000'), + ).toBeUndefined(); + }); + }); + + describe('throwaway initialization', () => { + it('creates a single random account', () => { + const keyring = makeKeyring({ type: 'throwaway' }); + + const accounts = keyring.getAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^0x[\da-f]{40}$/iu); + }); + + it('generates different addresses each time', () => { + const keyring1 = makeKeyring({ type: 'throwaway' }); + const keyring2 = makeKeyring({ type: 'throwaway' }); + + expect(keyring1.getAccounts()[0]).not.toBe(keyring2.getAccounts()[0]); + }); + + it('throws when trying to derive accounts', () => { + const keyring = makeKeyring({ type: 'throwaway' }); + + expect(() => keyring.deriveAccount(1)).toThrow( + 'Cannot derive accounts from a throwaway keyring', + ); + }); + + it('reports having keys', () => { + const keyring = makeKeyring({ type: 'throwaway' }); + expect(keyring.hasKeys()).toBe(true); + }); + + it('does not expose a mnemonic', () => { + const keyring = makeKeyring({ type: 'throwaway' }); + expect(keyring.getMnemonic()).toBeUndefined(); + }); + }); + }); + + describe('generateMnemonicPhrase', () => { + it('generates a 12-word mnemonic', () => { + const mnemonic = generateMnemonicPhrase(); + const words = mnemonic.split(' '); + expect(words).toHaveLength(12); + }); + + it('generates different mnemonics each time', () => { + const mnemonic1 = generateMnemonicPhrase(); + const mnemonic2 = generateMnemonicPhrase(); + expect(mnemonic1).not.toBe(mnemonic2); + }); + }); +}); diff --git a/packages/eth-wallet/src/lib/keyring.ts b/packages/eth-wallet/src/lib/keyring.ts new file mode 100644 index 000000000..1b24348c5 --- /dev/null +++ b/packages/eth-wallet/src/lib/keyring.ts @@ -0,0 +1,99 @@ +import { + english, + generateMnemonic, + mnemonicToAccount, + privateKeyToAccount, + generatePrivateKey, +} from 'viem/accounts'; +import type { HDAccount, LocalAccount } from 'viem/accounts'; + +import type { Address } from '../types.ts'; + +/** + * Options for initializing a keyring. + */ +export type KeyringInitOptions = + | { type: 'srp'; mnemonic: string } + | { type: 'throwaway' }; + +/** + * A keyring manages private keys and signing. Keys never leave this module. + */ +export type Keyring = { + getAccounts: () => Address[]; + deriveAccount: (index: number) => Address; + getAccount: (address: Address) => LocalAccount | undefined; + hasKeys: () => boolean; + getMnemonic: () => string | undefined; +}; + +/** + * Create a new keyring from an SRP mnemonic or a throwaway key. + * + * @param options - Initialization options. + * @returns The keyring instance. + */ +export function makeKeyring(options: KeyringInitOptions): Keyring { + const accounts = new Map(); + let mnemonic: string | undefined; + + if (options.type === 'srp') { + mnemonic = options.mnemonic; + // Derive the first account by default + deriveAccountInternal(0); + } else { + // Generate a throwaway private key + const privateKey = generatePrivateKey(); + const account = privateKeyToAccount(privateKey); + accounts.set(account.address.toLowerCase() as Address, account); + } + + /** + * Derive an account at the given BIP-44 index. + * + * @param index - The address index to derive. + * @returns The derived account address. + */ + function deriveAccountInternal(index: number): Address { + if (!mnemonic) { + throw new Error('Cannot derive accounts from a throwaway keyring'); + } + const account: HDAccount = mnemonicToAccount(mnemonic, { + addressIndex: index, + }); + const address = account.address.toLowerCase() as Address; + accounts.set(address, account); + return address; + } + + return { + getAccounts(): Address[] { + return [...accounts.keys()]; + }, + + deriveAccount(index: number): Address { + return deriveAccountInternal(index); + }, + + getAccount(address: Address): LocalAccount | undefined { + return accounts.get(address.toLowerCase() as Address); + }, + + hasKeys(): boolean { + return accounts.size > 0; + }, + + getMnemonic(): string | undefined { + return mnemonic; + }, + }; +} + +/** + * Generate a new random BIP-39 mnemonic phrase (12 words). + * + * @returns The mnemonic string. + */ +export function generateMnemonicPhrase(): string { + return generateMnemonic(english); +} diff --git a/packages/eth-wallet/src/lib/provider.test.ts b/packages/eth-wallet/src/lib/provider.test.ts new file mode 100644 index 000000000..8d3dd14ae --- /dev/null +++ b/packages/eth-wallet/src/lib/provider.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeProvider } from './provider.ts'; +import type { Provider } from './provider.ts'; + +const mockTransportRequest = vi.fn(); + +const mockClient = { + request: vi.fn(), + transport: { request: mockTransportRequest }, + sendRawTransaction: vi.fn(), + getBalance: vi.fn(), + getChainId: vi.fn(), + getTransactionCount: vi.fn(), +}; + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(() => mockClient), + }; +}); + +describe('lib/provider', () => { + let provider: Provider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = makeProvider({ chainId: 1, rpcUrl: 'https://rpc.example.com' }); + }); + + describe('makeProvider', () => { + it('creates a provider for a known chain', () => { + const knownProvider = makeProvider({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + expect(knownProvider).toBeDefined(); + expect(knownProvider.request).toBeDefined(); + expect(knownProvider.broadcastTransaction).toBeDefined(); + expect(knownProvider.getBalance).toBeDefined(); + expect(knownProvider.getChainId).toBeDefined(); + expect(knownProvider.getNonce).toBeDefined(); + }); + + it('creates a provider for a custom chain', () => { + const customProvider = makeProvider({ + chainId: 99999, + rpcUrl: 'https://custom-rpc.example.com', + name: 'Custom Chain', + }); + expect(customProvider).toBeDefined(); + }); + }); + + describe('request', () => { + it('forwards JSON-RPC calls to the client', async () => { + mockTransportRequest.mockResolvedValue('0x1'); + + const result = await provider.request('eth_chainId', []); + expect(result).toBe('0x1'); + expect(mockTransportRequest).toHaveBeenCalledWith({ + method: 'eth_chainId', + params: [], + }); + }); + }); + + describe('broadcastTransaction', () => { + it('sends a raw transaction', async () => { + const txHash = '0xabc123'; + mockClient.sendRawTransaction.mockResolvedValue(txHash); + + const result = await provider.broadcastTransaction('0xf86c...'); + expect(result).toBe(txHash); + }); + }); + + describe('getBalance', () => { + it('returns the balance as a hex string', async () => { + mockClient.getBalance.mockResolvedValue(1000000000000000000n); + + const balance = await provider.getBalance( + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + ); + expect(balance).toBe('0xde0b6b3a7640000'); + }); + }); + + describe('getChainId', () => { + it('returns the chain ID', async () => { + mockClient.getChainId.mockResolvedValue(1); + + const chainId = await provider.getChainId(); + expect(chainId).toBe(1); + }); + }); + + describe('getNonce', () => { + it('returns the transaction count', async () => { + mockClient.getTransactionCount.mockResolvedValue(42); + + const nonce = await provider.getNonce( + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + ); + expect(nonce).toBe(42); + }); + }); +}); diff --git a/packages/eth-wallet/src/lib/provider.ts b/packages/eth-wallet/src/lib/provider.ts new file mode 100644 index 000000000..7ea2a78cd --- /dev/null +++ b/packages/eth-wallet/src/lib/provider.ts @@ -0,0 +1,80 @@ +import { createPublicClient, http, defineChain } from 'viem'; +import type { Chain } from 'viem'; + +import type { Address, ChainConfig, Hex } from '../types.ts'; + +/** + * A JSON-RPC provider for Ethereum. + */ +export type Provider = { + request: (method: string, params?: unknown[]) => Promise; + broadcastTransaction: (signedTx: Hex) => Promise; + getBalance: (address: Address) => Promise; + getChainId: () => Promise; + getNonce: (address: Address) => Promise; +}; + +/** + * Create a viem Chain object from our ChainConfig. + * + * @param config - The chain configuration. + * @returns The viem Chain object. + */ +function toViemChain(config: ChainConfig): Chain { + return defineChain({ + id: config.chainId, + name: config.name ?? `Chain ${config.chainId}`, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { + default: { http: [config.rpcUrl] }, + }, + }); +} + +/** + * Create a JSON-RPC provider for the given chain. + * + * @param config - The chain configuration. + * @returns The provider instance. + */ +export function makeProvider(config: ChainConfig): Provider { + const chain = toViemChain(config); + const client = createPublicClient({ + chain, + transport: http(config.rpcUrl), + }); + + return { + async request(method: string, params?: unknown[]): Promise { + // Use the transport directly for generic JSON-RPC passthrough + const response = await client.transport.request({ + method, + params: params ?? [], + }); + return response; + }, + + async broadcastTransaction(signedTx: Hex): Promise { + return client.sendRawTransaction({ + serializedTransaction: signedTx, + }); + }, + + async getBalance(address: Address): Promise { + const balance = await client.getBalance({ + address, + }); + return `0x${balance.toString(16)}`; + }, + + async getChainId(): Promise { + return client.getChainId(); + }, + + async getNonce(address: Address): Promise { + return client.getTransactionCount({ + address, + }); + }, + }; +} diff --git a/packages/eth-wallet/src/lib/signing.test.ts b/packages/eth-wallet/src/lib/signing.test.ts new file mode 100644 index 000000000..aa87764b5 --- /dev/null +++ b/packages/eth-wallet/src/lib/signing.test.ts @@ -0,0 +1,129 @@ +import { privateKeyToAccount } from 'viem/accounts'; +import { describe, it, expect } from 'vitest'; + +import { signTransaction, signMessage, signTypedData } from './signing.ts'; + +// Deterministic test key (DO NOT use in production) +const TEST_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +const makeTestAccount = () => privateKeyToAccount(TEST_PRIVATE_KEY); + +describe('lib/signing', () => { + describe('signTransaction', () => { + it('signs an EIP-1559 transaction', async () => { + const account = makeTestAccount(); + const signed = await signTransaction({ + account, + tx: { + from: account.address.toLowerCase() as `0x${string}`, + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: '0xde0b6b3a7640000', + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x3b9aca00', + }, + }); + + expect(signed).toMatch(/^0x/u); + expect(signed.length).toBeGreaterThan(2); + }); + + it('signs a legacy transaction', async () => { + const account = makeTestAccount(); + const signed = await signTransaction({ + account, + tx: { + from: account.address.toLowerCase() as `0x${string}`, + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: '0xde0b6b3a7640000', + chainId: 1, + nonce: 0, + gasPrice: '0x3b9aca00', + }, + }); + + expect(signed).toMatch(/^0x/u); + }); + + it('signs a transaction with data', async () => { + const account = makeTestAccount(); + const signed = await signTransaction({ + account, + tx: { + from: account.address.toLowerCase() as `0x${string}`, + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + data: '0xa9059cbb0000000000000000000000000000000000000000000000000000000000000001', + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x3b9aca00', + }, + }); + + expect(signed).toMatch(/^0x/u); + }); + }); + + describe('signMessage', () => { + it('signs a personal message', async () => { + const account = makeTestAccount(); + const signature = await signMessage({ + account, + message: 'Hello, world!', + }); + + expect(signature).toMatch(/^0x/u); + // EIP-191 signatures are 65 bytes = 130 hex chars + 0x prefix + expect(signature).toHaveLength(132); + }); + + it('produces deterministic signatures', async () => { + const account = makeTestAccount(); + const sig1 = await signMessage({ account, message: 'test' }); + const sig2 = await signMessage({ account, message: 'test' }); + + expect(sig1).toBe(sig2); + }); + + it('produces different signatures for different messages', async () => { + const account = makeTestAccount(); + const sig1 = await signMessage({ account, message: 'hello' }); + const sig2 = await signMessage({ account, message: 'world' }); + + expect(sig1).not.toBe(sig2); + }); + }); + + describe('signTypedData', () => { + it('signs EIP-712 typed data', async () => { + const account = makeTestAccount(); + const signature = await signTypedData({ + account, + typedData: { + domain: { + name: 'Test', + version: '1', + chainId: 1, + verifyingContract: '0xcccccccccccccccccccccccccccccccccccccccc', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + }, + primaryType: 'Person', + message: { + name: 'Alice', + wallet: '0xcccccccccccccccccccccccccccccccccccccccc', + }, + }, + }); + + expect(signature).toMatch(/^0x/u); + expect(signature).toHaveLength(132); + }); + }); +}); diff --git a/packages/eth-wallet/src/lib/signing.ts b/packages/eth-wallet/src/lib/signing.ts new file mode 100644 index 000000000..ba152818d --- /dev/null +++ b/packages/eth-wallet/src/lib/signing.ts @@ -0,0 +1,88 @@ +import type { + TransactionSerializableEIP1559, + TransactionSerializableLegacy, +} from 'viem'; +import type { LocalAccount } from 'viem/accounts'; + +import type { Eip712TypedData, Hex, TransactionRequest } from '../types.ts'; + +/** + * Sign a transaction with the given account. + * + * @param options - Signing options. + * @param options.account - The local account to sign with. + * @param options.tx - The transaction request. + * @returns The signed transaction as a hex string. + */ +export async function signTransaction(options: { + account: LocalAccount; + tx: TransactionRequest; +}): Promise { + const { account, tx } = options; + + if (tx.maxFeePerGas) { + const eip1559Tx = { + to: tx.to, + type: 'eip1559' as const, + maxFeePerGas: BigInt(tx.maxFeePerGas), + ...(tx.value === undefined ? {} : { value: BigInt(tx.value) }), + ...(tx.data === undefined ? {} : { data: tx.data }), + ...(tx.nonce === undefined ? {} : { nonce: tx.nonce }), + ...(tx.gasLimit === undefined ? {} : { gas: BigInt(tx.gasLimit) }), + ...(tx.chainId === undefined ? {} : { chainId: tx.chainId }), + ...(tx.maxPriorityFeePerGas === undefined + ? {} + : { maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas) }), + } as TransactionSerializableEIP1559; + return account.signTransaction(eip1559Tx); + } + + const legacyTx = { + to: tx.to, + type: 'legacy' as const, + ...(tx.value === undefined ? {} : { value: BigInt(tx.value) }), + ...(tx.data === undefined ? {} : { data: tx.data }), + ...(tx.nonce === undefined ? {} : { nonce: tx.nonce }), + ...(tx.gasLimit === undefined ? {} : { gas: BigInt(tx.gasLimit) }), + ...(tx.chainId === undefined ? {} : { chainId: tx.chainId }), + ...(tx.gasPrice === undefined ? {} : { gasPrice: BigInt(tx.gasPrice) }), + } as TransactionSerializableLegacy; + return account.signTransaction(legacyTx); +} + +/** + * Sign a message using EIP-191 personal sign. + * + * @param options - Signing options. + * @param options.account - The local account to sign with. + * @param options.message - The message to sign. + * @returns The signature as a hex string. + */ +export async function signMessage(options: { + account: LocalAccount; + message: string; +}): Promise { + const { account, message } = options; + return account.signMessage({ message }); +} + +/** + * Sign EIP-712 typed data. + * + * @param options - Signing options. + * @param options.account - The local account to sign with. + * @param options.typedData - The EIP-712 typed data payload. + * @returns The signature as a hex string. + */ +export async function signTypedData(options: { + account: LocalAccount; + typedData: Eip712TypedData; +}): Promise { + const { account, typedData } = options; + return account.signTypedData({ + domain: typedData.domain as Record, + types: typedData.types as Record, + primaryType: typedData.primaryType, + message: typedData.message, + }); +} diff --git a/packages/eth-wallet/src/types.ts b/packages/eth-wallet/src/types.ts new file mode 100644 index 000000000..cea391523 --- /dev/null +++ b/packages/eth-wallet/src/types.ts @@ -0,0 +1,216 @@ +import { + array, + boolean, + define, + enums, + literal, + number, + object, + optional, + record, + string, + union, + unknown, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +// --------------------------------------------------------------------------- +// Hex string helpers +// --------------------------------------------------------------------------- + +/** + * A 0x-prefixed hex string. + */ +export type Hex = `0x${string}`; + +const HexStruct = define('Hex', (value) => { + return typeof value === 'string' && /^0x[\da-f]*$/iu.test(value); +}); + +/** + * A 0x-prefixed Ethereum address (20 bytes). + */ +export type Address = Hex; + +const AddressStruct = define
('Address', (value) => { + return typeof value === 'string' && /^0x[\da-f]{40}$/iu.test(value); +}); + +// --------------------------------------------------------------------------- +// Chain configuration +// --------------------------------------------------------------------------- + +export const ChainConfigStruct = object({ + chainId: number(), + rpcUrl: string(), + name: optional(string()), +}); + +export type ChainConfig = Infer; + +// --------------------------------------------------------------------------- +// Caveat types (MetaMask Delegation Framework / Gator) +// --------------------------------------------------------------------------- + +/** + * Supported caveat enforcer types. + * Each maps to a deployed enforcer contract on the delegation framework. + */ +export const CaveatTypeValues = [ + 'allowedTargets', + 'allowedMethods', + 'valueLte', + 'erc20TransferAmount', + 'limitedCalls', + 'timestamp', +] as const; + +export type CaveatType = (typeof CaveatTypeValues)[number]; + +const CaveatTypeStruct = enums(CaveatTypeValues); + +export const CaveatStruct = object({ + enforcer: AddressStruct, + terms: HexStruct, + args: optional(HexStruct), + type: CaveatTypeStruct, +}); + +export type Caveat = Infer; + +// --------------------------------------------------------------------------- +// Delegation +// --------------------------------------------------------------------------- + +export const DelegationStatusValues = ['pending', 'signed', 'revoked'] as const; + +export type DelegationStatus = (typeof DelegationStatusValues)[number]; + +export const DelegationStruct = object({ + id: string(), + delegator: AddressStruct, + delegate: AddressStruct, + authority: HexStruct, + caveats: array(CaveatStruct), + salt: HexStruct, + signature: optional(HexStruct), + chainId: number(), + status: enums(DelegationStatusValues), +}); + +export type Delegation = Infer; + +// --------------------------------------------------------------------------- +// Signing request (used for peer wallet communication) +// --------------------------------------------------------------------------- + +export const SigningRequestStruct = union([ + object({ + type: literal('transaction'), + tx: object({ + from: AddressStruct, + to: AddressStruct, + value: optional(HexStruct), + data: optional(HexStruct), + nonce: optional(number()), + gasLimit: optional(HexStruct), + gasPrice: optional(HexStruct), + maxFeePerGas: optional(HexStruct), + maxPriorityFeePerGas: optional(HexStruct), + chainId: optional(number()), + }), + }), + object({ + type: literal('typedData'), + data: record(string(), unknown()), + }), + object({ + type: literal('message'), + message: string(), + account: AddressStruct, + }), +]); + +export type SigningRequest = Infer; + +// --------------------------------------------------------------------------- +// Transaction types +// --------------------------------------------------------------------------- + +export const TransactionRequestStruct = object({ + from: AddressStruct, + to: AddressStruct, + value: optional(HexStruct), + data: optional(HexStruct), + nonce: optional(number()), + gasLimit: optional(HexStruct), + gasPrice: optional(HexStruct), + maxFeePerGas: optional(HexStruct), + maxPriorityFeePerGas: optional(HexStruct), + chainId: optional(number()), +}); + +export type TransactionRequest = Infer; + +// --------------------------------------------------------------------------- +// Wallet capabilities introspection +// --------------------------------------------------------------------------- + +export const WalletCapabilitiesStruct = object({ + hasLocalKeys: boolean(), + localAccounts: array(AddressStruct), + delegationCount: number(), + hasPeerWallet: boolean(), +}); + +export type WalletCapabilities = Infer; + +// --------------------------------------------------------------------------- +// Action descriptor (for delegation matching) +// --------------------------------------------------------------------------- + +export const ActionStruct = object({ + to: AddressStruct, + value: optional(HexStruct), + data: optional(HexStruct), +}); + +export type Action = Infer; + +// --------------------------------------------------------------------------- +// Delegation creation options +// --------------------------------------------------------------------------- + +export const CreateDelegationOptionsStruct = object({ + delegate: AddressStruct, + caveats: array(CaveatStruct), + chainId: number(), + salt: optional(HexStruct), +}); + +export type CreateDelegationOptions = Infer< + typeof CreateDelegationOptionsStruct +>; + +// --------------------------------------------------------------------------- +// EIP-712 typed data (generic representation) +// --------------------------------------------------------------------------- + +export const Eip712DomainStruct = object({ + name: optional(string()), + version: optional(string()), + chainId: optional(number()), + verifyingContract: optional(AddressStruct), + salt: optional(HexStruct), +}); + +export type Eip712Domain = Infer; + +export const Eip712TypedDataStruct = object({ + domain: Eip712DomainStruct, + types: record(string(), array(object({ name: string(), type: string() }))), + primaryType: string(), + message: record(string(), unknown()), +}); + +export type Eip712TypedData = Infer; diff --git a/packages/eth-wallet/src/vats/coordinator-vat.test.ts b/packages/eth-wallet/src/vats/coordinator-vat.test.ts new file mode 100644 index 000000000..e3f40d37c --- /dev/null +++ b/packages/eth-wallet/src/vats/coordinator-vat.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { buildRootObject as buildDelegationRoot } from './delegation-vat.ts'; +import { buildRootObject as buildKeyringRoot } from './keyring-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; +import { encodeAllowedTargets, makeCaveat } from '../lib/caveats.ts'; +import type { Address, Hex, TransactionRequest } from '../types.ts'; + +// Mock E() to call methods directly on plain objects +vi.mock('@endo/eventual-send', () => ({ + E: (target: Record unknown>) => { + return new Proxy(target, { + get(_target, prop: string) { + return (...args: unknown[]) => { + const method = _target[prop]; + if (typeof method !== 'function') { + throw new Error(`${prop} is not a function on target`); + } + return method.call(_target, ...args); + }; + }, + }); + }, +})); + +// Dynamic import after mocking +const { buildRootObject } = await import('./coordinator-vat.ts'); + +const TEST_MNEMONIC = + 'test test test test test test test test test test test junk'; +const TARGET = '0x1234567890abcdef1234567890abcdef12345678' as Address; + +function makeMockProviderVat() { + return { + bootstrap: vi.fn(), + configure: vi.fn(), + request: vi.fn(), + broadcastTransaction: vi.fn().mockResolvedValue('0xtxhash'), + getBalance: vi.fn(), + getChainId: vi.fn(), + getNonce: vi.fn(), + }; +} + +describe('coordinator-vat', () => { + let coordinatorBaggage: ReturnType; + let keyringBaggage: ReturnType; + let delegationBaggage: ReturnType; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let coordinator: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let keyringVat: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let delegationVat: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let providerVat: any; + + beforeEach(async () => { + coordinatorBaggage = makeMockBaggage(); + keyringBaggage = makeMockBaggage(); + delegationBaggage = makeMockBaggage(); + + // Build real keyring and delegation vats (unit test with real inner vats) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keyringVat = buildKeyringRoot({}, undefined, keyringBaggage as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delegationVat = buildDelegationRoot({}, {}, delegationBaggage as any); + providerVat = makeMockProviderVat(); + + coordinator = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coordinatorBaggage as any, + ); + + await coordinator.bootstrap( + { keyring: keyringVat, provider: providerVat, delegation: delegationVat }, + {}, + ); + }); + + describe('bootstrap', () => { + it('stores vat references in baggage', () => { + expect(coordinatorBaggage.has('keyringVat')).toBe(true); + expect(coordinatorBaggage.has('providerVat')).toBe(true); + expect(coordinatorBaggage.has('delegationVat')).toBe(true); + }); + }); + + describe('initializeKeyring', () => { + it('initializes the keyring with SRP', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + expect(accounts).toHaveLength(1); + }); + + it('initializes the keyring with throwaway key', async () => { + await coordinator.initializeKeyring({ type: 'throwaway' }); + + const accounts = await coordinator.getAccounts(); + expect(accounts).toHaveLength(1); + }); + }); + + describe('signing strategy resolution', () => { + describe('local key signing', () => { + it('signs with local key when account is owned', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + const tx: TransactionRequest = { + from: accounts[0], + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + value: '0xde0b6b3a7640000' as Hex, + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }; + + const signed = await coordinator.signTransaction(tx); + expect(signed).toMatch(/^0x/u); + }); + }); + + describe('delegation-based signing', () => { + it('uses delegation path when a matching delegation exists', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + const delegator = accounts[0] as Address; + + // Create and sign a delegation covering the target + const delegation = await delegationVat.createDelegation({ + delegator, + delegate: delegator, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + await delegationVat.storeSigned(delegation.id, '0xdeadbeef' as Hex); + + const tx: TransactionRequest = { + from: delegator, + to: TARGET, + value: '0x0' as Hex, + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }; + + const signed = await coordinator.signTransaction(tx); + expect(signed).toMatch(/^0x/u); + }); + }); + + describe('no authority', () => { + it('rejects when no signing strategy is available', async () => { + await coordinator.initializeKeyring({ type: 'throwaway' }); + + const tx: TransactionRequest = { + from: '0x0000000000000000000000000000000000000099' as Address, + to: TARGET, + chainId: 1, + nonce: 0, + }; + + await expect(coordinator.signTransaction(tx)).rejects.toThrow( + 'No authority to sign this transaction', + ); + }); + }); + + describe('peer wallet fallback', () => { + it('forwards to peer wallet when no local authority', async () => { + const mockPeerWallet = { + handleSigningRequest: vi + .fn() + .mockResolvedValue('0xpeersigned' as Hex), + }; + + // Build coordinator with peer wallet in baggage + const freshBaggage = makeMockBaggage(); + freshBaggage.init('peerWallet', mockPeerWallet); + + const coordinatorWithPeer = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + freshBaggage as any, + ); + + const tx: TransactionRequest = { + from: '0x0000000000000000000000000000000000000099' as Address, + to: TARGET, + chainId: 1, + nonce: 0, + }; + + const signed = await coordinatorWithPeer.signTransaction(tx); + expect(signed).toBe('0xpeersigned'); + expect(mockPeerWallet.handleSigningRequest).toHaveBeenCalledWith({ + type: 'transaction', + tx, + }); + }); + }); + }); + + describe('sendTransaction', () => { + it('signs and broadcasts a transaction', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + const tx: TransactionRequest = { + from: accounts[0], + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + value: '0xde0b6b3a7640000' as Hex, + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }; + + const txHash = await coordinator.sendTransaction(tx); + expect(txHash).toBe('0xtxhash'); + expect(providerVat.broadcastTransaction).toHaveBeenCalled(); + }); + }); + + describe('signMessage', () => { + it('signs a message with local key', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const signature = await coordinator.signMessage('Hello, world!'); + expect(signature).toMatch(/^0x/u); + expect(signature).toHaveLength(132); + }); + + it('rejects when no authority', async () => { + const emptyBaggage = makeMockBaggage(); + + const emptyCoordinator = buildRootObject( + {}, + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emptyBaggage as any, + ); + await emptyCoordinator.bootstrap({ provider: providerVat }, {}); + + await expect(emptyCoordinator.signMessage('test')).rejects.toThrow( + 'No authority to sign message', + ); + }); + }); + + describe('delegation management', () => { + it('creates a delegation (full flow)', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const delegation = await coordinator.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ], + chainId: 1, + }); + + expect(delegation.status).toBe('signed'); + expect(delegation.signature).toBeDefined(); + }); + + it('lists delegations', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + await coordinator.createDelegation({ + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + caveats: [], + chainId: 1, + }); + + const delegations = await coordinator.listDelegations(); + expect(delegations).toHaveLength(1); + }); + }); + + describe('getCapabilities', () => { + it('reports wallet capabilities', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const caps = await coordinator.getCapabilities(); + expect(caps).toStrictEqual({ + hasLocalKeys: true, + localAccounts: expect.arrayContaining([ + expect.stringMatching(/^0x[\da-f]{40}$/iu), + ]), + delegationCount: 0, + hasPeerWallet: false, + }); + }); + }); + + describe('handleSigningRequest', () => { + it('handles transaction signing requests', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const accounts = await coordinator.getAccounts(); + const signed = await coordinator.handleSigningRequest({ + type: 'transaction', + tx: { + from: accounts[0], + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, + value: '0xde0b6b3a7640000' as Hex, + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00' as Hex, + maxPriorityFeePerGas: '0x3b9aca00' as Hex, + }, + }); + + expect(signed).toMatch(/^0x/u); + }); + + it('handles message signing requests', async () => { + await coordinator.initializeKeyring({ + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + const signed = await coordinator.handleSigningRequest({ + type: 'message', + message: 'Hello', + }); + + expect(signed).toMatch(/^0x/u); + }); + + it('rejects unknown request types', async () => { + await expect( + coordinator.handleSigningRequest({ type: 'unknown' }), + ).rejects.toThrow('Unknown signing request type'); + }); + }); +}); diff --git a/packages/eth-wallet/src/vats/coordinator-vat.ts b/packages/eth-wallet/src/vats/coordinator-vat.ts new file mode 100644 index 000000000..ed6f690e5 --- /dev/null +++ b/packages/eth-wallet/src/vats/coordinator-vat.ts @@ -0,0 +1,448 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import type { + Action, + Address, + ChainConfig, + CreateDelegationOptions, + Delegation, + Eip712TypedData, + Hex, + TransactionRequest, + WalletCapabilities, +} from '../types.ts'; + +/** + * Vat powers for the coordinator vat. + */ +type VatPowers = Record; + +/** + * Vat references available in the wallet subcluster. + */ +type WalletVats = { + keyring?: unknown; + provider?: unknown; + delegation?: unknown; +}; + +/** + * Services available to the wallet subcluster. + */ +type WalletServices = { + ocapURLIssuerService?: unknown; + ocapURLRedemptionService?: unknown; +}; + +// Typed facets for E() calls (avoid `any` by using explicit method signatures) +type KeyringFacet = { + initialize: (options: { type: string; mnemonic?: string }) => Promise; + hasKeys: () => Promise; + getAccounts: () => Promise; + deriveAccount: (index: number) => Promise
; + signTransaction: (tx: TransactionRequest) => Promise; + signTypedData: (data: Eip712TypedData) => Promise; + signMessage: (message: string, from?: Address) => Promise; +}; + +type ProviderFacet = { + configure: (config: ChainConfig) => Promise; + request: (method: string, params?: unknown[]) => Promise; + broadcastTransaction: (signedTx: Hex) => Promise; +}; + +type DelegationFacet = { + createDelegation: ( + options: CreateDelegationOptions & { delegator: Address }, + ) => Promise; + prepareDelegationForSigning: (id: string) => Promise; + storeSigned: (id: string, signature: Hex) => Promise; + receiveDelegation: (delegation: Delegation) => Promise; + findDelegationForAction: (action: Action) => Promise; + getDelegation: (id: string) => Promise; + listDelegations: () => Promise; + revokeDelegation: (id: string) => Promise; +}; + +type PeerWalletFacet = { + handleSigningRequest: (request: { + type: string; + tx?: TransactionRequest; + data?: Eip712TypedData; + message?: string; + account?: Address; + }) => Promise; +}; + +type OcapURLIssuerFacet = { + issue: (target: unknown) => Promise; +}; + +type OcapURLRedemptionFacet = { + redeem: (url: string) => Promise; +}; + +/** + * Build the root object for the coordinator vat (bootstrap vat). + * + * The coordinator orchestrates signing strategy resolution, delegation + * management, and peer wallet communication. It is the public API of + * the wallet subcluster. + * + * @param _vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters. + * @param baggage - Root of vat's persistent state. + * @returns The root object for the coordinator vat. + */ +export function buildRootObject( + _vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + // References to other vats (set during bootstrap) + let keyringVat: KeyringFacet | undefined; + let providerVat: ProviderFacet | undefined; + let delegationVat: DelegationFacet | undefined; + let issuerService: OcapURLIssuerFacet | undefined; + let redemptionService: OcapURLRedemptionFacet | undefined; + + // Peer wallet reference (Phase 2: set via connectToPeer) + let peerWallet: PeerWalletFacet | undefined; + + // Restore vat references from baggage if available (resuscitation) + if (baggage.has('keyringVat')) { + keyringVat = baggage.get('keyringVat') as KeyringFacet; + } + if (baggage.has('providerVat')) { + providerVat = baggage.get('providerVat') as ProviderFacet; + } + if (baggage.has('delegationVat')) { + delegationVat = baggage.get('delegationVat') as DelegationFacet; + } + if (baggage.has('peerWallet')) { + peerWallet = baggage.get('peerWallet') as PeerWalletFacet; + } + + /** + * Persist a baggage key-value pair, handling both init and update. + * + * @param key - The baggage key. + * @param value - The value to persist. + */ + function persistBaggage(key: string, value: unknown): void { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + } + + /** + * Resolve the signing strategy for a transaction. + * Priority: delegation → local key → peer wallet → reject + * + * @param tx - The transaction request to sign. + * @returns The signed transaction as a hex string. + */ + async function resolveTransactionSigning( + tx: TransactionRequest, + ): Promise { + // Strategy 1: Check if a delegation covers this action + if (delegationVat) { + const delegation = await E(delegationVat).findDelegationForAction({ + to: tx.to, + value: tx.value, + data: tx.data, + }); + + if (delegation) { + // For MVP, we sign the original transaction with the local key + // In a full implementation, this would prepare a UserOp and redeem the delegation + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length > 0) { + return E(keyringVat).signTransaction(tx); + } + } + } + } + + // Strategy 2: Check if local keyring owns this account + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.includes(tx.from.toLowerCase() as Address)) { + return E(keyringVat).signTransaction(tx); + } + } + + // Strategy 3: Check if a peer wallet can handle it (Phase 2) + if (peerWallet) { + return E(peerWallet).handleSigningRequest({ + type: 'transaction', + tx, + }); + } + + throw new Error('No authority to sign this transaction'); + } + + return makeDefaultExo('walletCoordinator', { + // ------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------ + + async bootstrap(vats: WalletVats, services: WalletServices): Promise { + keyringVat = vats.keyring as KeyringFacet | undefined; + providerVat = vats.provider as ProviderFacet | undefined; + delegationVat = vats.delegation as DelegationFacet | undefined; + issuerService = services.ocapURLIssuerService as + | OcapURLIssuerFacet + | undefined; + redemptionService = services.ocapURLRedemptionService as + | OcapURLRedemptionFacet + | undefined; + + if (keyringVat) { + persistBaggage('keyringVat', keyringVat); + } + if (providerVat) { + persistBaggage('providerVat', providerVat); + } + if (delegationVat) { + persistBaggage('delegationVat', delegationVat); + } + }, + + // ------------------------------------------------------------------ + // Wallet initialization + // ------------------------------------------------------------------ + + async initializeKeyring(options: { + type: 'srp' | 'throwaway'; + mnemonic?: string; + }): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + const initOptions = + options.type === 'srp' + ? { type: 'srp' as const, mnemonic: options.mnemonic ?? '' } + : { type: 'throwaway' as const }; + await E(keyringVat).initialize(initOptions); + }, + + async configureProvider(chainConfig: ChainConfig): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + await E(providerVat).configure(chainConfig); + }, + + // ------------------------------------------------------------------ + // Public wallet API + // ------------------------------------------------------------------ + + async getAccounts(): Promise { + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + + // In future: merge with delegation-covered accounts + return localAccounts; + }, + + async signTransaction(tx: TransactionRequest): Promise { + return resolveTransactionSigning(tx); + }, + + async sendTransaction(tx: TransactionRequest): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + const signedTx = await resolveTransactionSigning(tx); + return E(providerVat).broadcastTransaction(signedTx); + }, + + async signTypedData(data: Eip712TypedData): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signTypedData(data); + } + } + + if (peerWallet) { + return E(peerWallet).handleSigningRequest({ + type: 'typedData', + data, + }); + } + + throw new Error('No authority to sign typed data'); + }, + + async signMessage(message: string, account?: Address): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signMessage(message, account); + } + } + + if (peerWallet) { + return E(peerWallet).handleSigningRequest({ + type: 'message', + message, + account: account ?? ('' as Address), + }); + } + + throw new Error('No authority to sign message'); + }, + + async request(method: string, params?: unknown[]): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + return E(providerVat).request(method, params); + }, + + // ------------------------------------------------------------------ + // Delegation management + // ------------------------------------------------------------------ + + async createDelegation(opts: CreateDelegationOptions): Promise { + if (!delegationVat || !keyringVat) { + throw new Error('Delegation or keyring vat not available'); + } + + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length === 0) { + throw new Error('No accounts available to create delegation'); + } + const delegator = accounts[0] as Address; + + const delegation = await E(delegationVat).createDelegation({ + ...opts, + delegator, + }); + + const typedData = await E(delegationVat).prepareDelegationForSigning( + delegation.id, + ); + + const signature = await E(keyringVat).signTypedData(typedData); + + await E(delegationVat).storeSigned(delegation.id, signature); + + return E(delegationVat).getDelegation(delegation.id); + }, + + async receiveDelegation(delegation: Delegation): Promise { + if (!delegationVat) { + throw new Error('Delegation vat not available'); + } + await E(delegationVat).receiveDelegation(delegation); + }, + + async revokeDelegation(id: string): Promise { + if (!delegationVat) { + throw new Error('Delegation vat not available'); + } + await E(delegationVat).revokeDelegation(id); + }, + + async listDelegations(): Promise { + if (!delegationVat) { + return []; + } + return E(delegationVat).listDelegations(); + }, + + // ------------------------------------------------------------------ + // Peer wallet connectivity (Phase 2 stubs) + // ------------------------------------------------------------------ + + async issueOcapUrl(): Promise { + if (!issuerService) { + throw new Error('OCAP URL issuer service not available'); + } + return E(issuerService).issue(this); + }, + + async connectToPeer(ocapUrl: string): Promise { + if (!redemptionService) { + throw new Error('OCAP URL redemption service not available'); + } + peerWallet = (await E(redemptionService).redeem( + ocapUrl, + )) as PeerWalletFacet; + persistBaggage('peerWallet', peerWallet); + }, + + async handleSigningRequest(request: { + type: string; + tx?: TransactionRequest; + data?: Eip712TypedData; + message?: string; + account?: Address; + }): Promise { + switch (request.type) { + case 'transaction': + if (!request.tx) { + throw new Error('Missing transaction in signing request'); + } + if (!keyringVat) { + throw new Error('No local keyring to handle signing request'); + } + return E(keyringVat).signTransaction(request.tx); + + case 'typedData': + if (!request.data) { + throw new Error('Missing typed data in signing request'); + } + if (!keyringVat) { + throw new Error('No local keyring to handle signing request'); + } + return E(keyringVat).signTypedData(request.data); + + case 'message': + if (!request.message) { + throw new Error('Missing message in signing request'); + } + if (!keyringVat) { + throw new Error('No local keyring to handle signing request'); + } + return E(keyringVat).signMessage(request.message, request.account); + + default: + throw new Error(`Unknown signing request type: ${request.type}`); + } + }, + + // ------------------------------------------------------------------ + // Introspection + // ------------------------------------------------------------------ + + async getCapabilities(): Promise { + const hasLocalKeys = keyringVat ? await E(keyringVat).hasKeys() : false; + + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + + const delegations: Delegation[] = delegationVat + ? await E(delegationVat).listDelegations() + : []; + + return { + hasLocalKeys, + localAccounts, + delegationCount: delegations.length, + hasPeerWallet: peerWallet !== undefined, + }; + }, + }); +} diff --git a/packages/eth-wallet/src/vats/delegation-vat.test.ts b/packages/eth-wallet/src/vats/delegation-vat.test.ts new file mode 100644 index 000000000..26ec4a13b --- /dev/null +++ b/packages/eth-wallet/src/vats/delegation-vat.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { buildRootObject } from './delegation-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; +import { encodeAllowedTargets, makeCaveat } from '../lib/caveats.ts'; +import { finalizeDelegation, makeDelegation } from '../lib/delegation.ts'; +import type { Address, Hex } from '../types.ts'; + +const ALICE = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as Address; +const BOB = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address; +const TARGET = '0x1234567890abcdef1234567890abcdef12345678' as Address; + +describe('delegation-vat', () => { + let baggage: ReturnType; + let root: ReturnType; + + beforeEach(() => { + baggage = makeMockBaggage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + root = buildRootObject({}, {}, baggage as any); + }); + + describe('bootstrap', () => { + it('completes without error', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (root as any).bootstrap()).toBeUndefined(); + }); + }); + + describe('createDelegation', () => { + it('creates an unsigned delegation', async () => { + const caveats = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegation = await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats, + chainId: 1, + }); + + expect(delegation.delegator).toBe(ALICE); + expect(delegation.delegate).toBe(BOB); + expect(delegation.status).toBe('pending'); + expect(delegation.caveats).toHaveLength(1); + }); + + it('persists the delegation in baggage', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + expect(baggage.has('delegations')).toBe(true); + }); + }); + + describe('prepareDelegationForSigning', () => { + it('returns EIP-712 typed data', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegation = await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const typedData = await (root as any).prepareDelegationForSigning( + delegation.id, + ); + + expect(typedData.primaryType).toBe('Delegation'); + expect(typedData.types).toHaveProperty('Delegation'); + expect(typedData.domain).toHaveProperty('chainId', 1); + }); + + it('throws for unknown delegation', async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).prepareDelegationForSigning('nonexistent'), + ).rejects.toThrow('Delegation not found'); + }); + }); + + describe('storeSigned', () => { + it('marks delegation as signed', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegation = await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + const signature = '0xdeadbeef' as Hex; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).storeSigned(delegation.id, signature); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stored = await (root as any).getDelegation(delegation.id); + expect(stored.status).toBe('signed'); + expect(stored.signature).toBe(signature); + }); + }); + + describe('receiveDelegation', () => { + it('stores a signed delegation from a peer', async () => { + const delegation = finalizeDelegation( + makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }), + '0xdeadbeef' as Hex, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).receiveDelegation(delegation); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stored = await (root as any).getDelegation(delegation.id); + expect(stored.status).toBe('signed'); + }); + + it('rejects unsigned delegations', async () => { + const delegation = makeDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).receiveDelegation(delegation), + ).rejects.toThrow('Can only receive signed delegations'); + }); + }); + + describe('findDelegationForAction', () => { + it('finds a matching delegation', async () => { + const caveats = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TARGET]), + }), + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegation = await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats, + chainId: 1, + }); + + // Sign it + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const found = await (root as any).findDelegationForAction({ + to: TARGET, + }); + + expect(found).toBeDefined(); + expect(found.id).toBe(delegation.id); + }); + + it('returns undefined when no delegation matches', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const found = await (root as any).findDelegationForAction({ + to: TARGET, + }); + + expect(found).toBeUndefined(); + }); + }); + + describe('listDelegations', () => { + it('lists all delegations', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegations = await (root as any).listDelegations(); + expect(delegations).toHaveLength(2); + }); + }); + + describe('revokeDelegation', () => { + it('marks a delegation as revoked', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const delegation = await (root as any).createDelegation({ + delegator: ALICE, + delegate: BOB, + caveats: [], + chainId: 1, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).revokeDelegation(delegation.id); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const revoked = await (root as any).getDelegation(delegation.id); + expect(revoked.status).toBe('revoked'); + }); + }); +}); diff --git a/packages/eth-wallet/src/vats/delegation-vat.ts b/packages/eth-wallet/src/vats/delegation-vat.ts new file mode 100644 index 000000000..ff9df77d3 --- /dev/null +++ b/packages/eth-wallet/src/vats/delegation-vat.ts @@ -0,0 +1,147 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; +import { + makeDelegation, + prepareDelegationTypedData, + delegationMatchesAction, + finalizeDelegation, +} from '../lib/delegation.ts'; +import type { + Action, + Address, + CreateDelegationOptions, + Delegation, + Eip712TypedData, + Hex, +} from '../types.ts'; + +/** + * Vat powers for the delegation vat. + */ +type VatPowers = Record; + +/** + * Build the root object for the delegation vat. + * + * The delegation vat manages Gator delegations: creating, storing, + * signing, matching, and revoking them. + * + * @param _vatPowers - Special powers granted to this vat. + * @param parameters - Initialization parameters. + * @param parameters.delegationManagerAddress - The delegation manager contract address. + * @param baggage - Root of vat's persistent state. + * @returns The root object for the delegation vat. + */ +export function buildRootObject( + _vatPowers: VatPowers, + parameters: { delegationManagerAddress?: Address }, + baggage: Baggage, +): object { + const delegationManagerAddress = + parameters.delegationManagerAddress ?? DEFAULT_DELEGATION_MANAGER; + + // Restore delegations from baggage + const delegations: Map = baggage.has('delegations') + ? new Map( + Object.entries( + baggage.get('delegations') as Record, + ), + ) + : new Map(); + + /** + * + */ + function persistDelegations(): void { + const serialized = Object.fromEntries(delegations); + if (baggage.has('delegations')) { + baggage.set('delegations', serialized); + } else { + baggage.init('delegations', serialized); + } + } + + return makeDefaultExo('walletDelegation', { + async bootstrap(): Promise { + // No services needed for the delegation vat + }, + + async createDelegation( + options: CreateDelegationOptions & { delegator: Address }, + ): Promise { + const delegation = makeDelegation({ + delegator: options.delegator, + delegate: options.delegate, + caveats: options.caveats, + chainId: options.chainId, + ...(options.salt ? { salt: options.salt } : {}), + }); + delegations.set(delegation.id, delegation); + persistDelegations(); + return delegation; + }, + + async prepareDelegationForSigning(id: string): Promise { + const delegation = delegations.get(id); + if (!delegation) { + throw new Error(`Delegation not found: ${id}`); + } + return prepareDelegationTypedData({ + delegation, + verifyingContract: delegationManagerAddress, + }); + }, + + async storeSigned(id: string, signature: Hex): Promise { + const delegation = delegations.get(id); + if (!delegation) { + throw new Error(`Delegation not found: ${id}`); + } + const signed = finalizeDelegation(delegation, signature); + delegations.set(id, signed); + persistDelegations(); + }, + + async receiveDelegation(delegation: Delegation): Promise { + if (delegation.status !== 'signed') { + throw new Error('Can only receive signed delegations'); + } + delegations.set(delegation.id, delegation); + persistDelegations(); + }, + + async findDelegationForAction( + action: Action, + ): Promise { + for (const delegation of delegations.values()) { + if (delegationMatchesAction(delegation, action)) { + return delegation; + } + } + return undefined; + }, + + async getDelegation(id: string): Promise { + const delegation = delegations.get(id); + if (!delegation) { + throw new Error(`Delegation not found: ${id}`); + } + return delegation; + }, + + async listDelegations(): Promise { + return [...delegations.values()]; + }, + + async revokeDelegation(id: string): Promise { + const delegation = delegations.get(id); + if (!delegation) { + throw new Error(`Delegation not found: ${id}`); + } + delegations.set(id, { ...delegation, status: 'revoked' }); + persistDelegations(); + }, + }); +} diff --git a/packages/eth-wallet/src/vats/keyring-vat.test.ts b/packages/eth-wallet/src/vats/keyring-vat.test.ts new file mode 100644 index 000000000..34d5bb483 --- /dev/null +++ b/packages/eth-wallet/src/vats/keyring-vat.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { buildRootObject } from './keyring-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; + +const TEST_MNEMONIC = + 'test test test test test test test test test test test junk'; + +describe('keyring-vat', () => { + let baggage: ReturnType; + let root: ReturnType; + + beforeEach(() => { + baggage = makeMockBaggage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + root = buildRootObject({}, undefined, baggage as any); + }); + + describe('bootstrap', () => { + it('completes without error', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (root as any).bootstrap()).toBeUndefined(); + }); + }); + + describe('initialize', () => { + it('initializes with SRP mnemonic', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasKeys = await (root as any).hasKeys(); + expect(hasKeys).toBe(true); + }); + + it('initializes with throwaway key', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'throwaway' }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasKeys = await (root as any).hasKeys(); + expect(hasKeys).toBe(true); + }); + + it('throws if already initialized', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'throwaway' }); + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).initialize({ type: 'throwaway' }), + ).rejects.toThrow('Keyring already initialized'); + }); + + it('persists init options in baggage', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + expect(baggage.has('keyringInit')).toBe(true); + }); + }); + + describe('getAccounts', () => { + it('returns empty array when not initialized', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accounts = await (root as any).getAccounts(); + expect(accounts).toStrictEqual([]); + }); + + it('returns accounts after SRP initialization', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accounts = await (root as any).getAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^0x[\da-f]{40}$/iu); + }); + }); + + describe('deriveAccount', () => { + it('derives a new account at a given index', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const address = await (root as any).deriveAccount(1); + expect(address).toMatch(/^0x[\da-f]{40}$/iu); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accounts = await (root as any).getAccounts(); + expect(accounts).toHaveLength(2); + }); + + it('throws when not initialized', async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).deriveAccount(0), + ).rejects.toThrow('Keyring not initialized'); + }); + }); + + describe('signTransaction', () => { + it('signs a transaction', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accounts = await (root as any).getAccounts(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signed = await (root as any).signTransaction({ + from: accounts[0], + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: '0xde0b6b3a7640000', + chainId: 1, + nonce: 0, + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x3b9aca00', + }); + + expect(signed).toMatch(/^0x/u); + }); + + it('throws for unknown account', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).signTransaction({ + from: '0x0000000000000000000000000000000000000000', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + chainId: 1, + nonce: 0, + }), + ).rejects.toThrow('No key for account'); + }); + }); + + describe('signMessage', () => { + it('signs a personal message', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signature = await (root as any).signMessage('Hello, world!'); + expect(signature).toMatch(/^0x/u); + expect(signature).toHaveLength(132); + }); + }); + + describe('resuscitation from baggage', () => { + it('restores keyring from persisted init options', async () => { + // Initialize and persist + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).initialize({ type: 'srp', mnemonic: TEST_MNEMONIC }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accountsBefore = await (root as any).getAccounts(); + + // Create a new root object with the same baggage (simulates resuscitation) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const restoredRoot = buildRootObject({}, undefined, baggage as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accountsAfter = await (restoredRoot as any).getAccounts(); + expect(accountsAfter).toStrictEqual(accountsBefore); + }); + }); +}); diff --git a/packages/eth-wallet/src/vats/keyring-vat.ts b/packages/eth-wallet/src/vats/keyring-vat.ts new file mode 100644 index 000000000..505e6949f --- /dev/null +++ b/packages/eth-wallet/src/vats/keyring-vat.ts @@ -0,0 +1,133 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import { makeKeyring } from '../lib/keyring.ts'; +import type { Keyring, KeyringInitOptions } from '../lib/keyring.ts'; +import { signTransaction, signMessage, signTypedData } from '../lib/signing.ts'; +import type { + Address, + Eip712TypedData, + Hex, + TransactionRequest, +} from '../types.ts'; + +/** + * Vat powers for the keyring vat. + */ +type VatPowers = Record; + +/** + * Build the root object for the keyring vat. + * + * The keyring vat isolates private keys. Keys never leave this vat. + * Other vats send unsigned payloads and receive signed bytes. + * + * @param _vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters. + * @param baggage - Root of vat's persistent state. + * @returns The root object for the keyring vat. + */ +export function buildRootObject( + _vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + let keyring: Keyring | undefined; + + // Restore keyring from baggage if previously initialized + if (baggage.has('keyringInit')) { + const initOptions = baggage.get('keyringInit') as KeyringInitOptions; + keyring = makeKeyring(initOptions); + } + + return makeDefaultExo('walletKeyring', { + async bootstrap(): Promise { + // No services needed for the keyring vat + }, + + async initialize(options: KeyringInitOptions): Promise { + if (keyring) { + throw new Error('Keyring already initialized'); + } + keyring = makeKeyring(options); + + // Persist the init options so keyring can be rebuilt on resuscitation + if (baggage.has('keyringInit')) { + baggage.set('keyringInit', options); + } else { + baggage.init('keyringInit', options); + } + }, + + async hasKeys(): Promise { + return keyring?.hasKeys() ?? false; + }, + + async deriveAccount(index: number): Promise
{ + if (!keyring) { + throw new Error('Keyring not initialized'); + } + const address = keyring.deriveAccount(index); + + // Persist the derived account count + const accounts = keyring.getAccounts(); + if (baggage.has('accountCount')) { + baggage.set('accountCount', accounts.length); + } else { + baggage.init('accountCount', accounts.length); + } + + return address; + }, + + async getAccounts(): Promise { + if (!keyring) { + return []; + } + return keyring.getAccounts(); + }, + + async signTransaction(tx: TransactionRequest): Promise { + if (!keyring) { + throw new Error('Keyring not initialized'); + } + const account = keyring.getAccount(tx.from); + if (!account) { + throw new Error(`No key for account ${tx.from}`); + } + return signTransaction({ account, tx }); + }, + + async signTypedData(typedData: Eip712TypedData): Promise { + if (!keyring) { + throw new Error('Keyring not initialized'); + } + // Use the first account for typed data signing + const accounts = keyring.getAccounts(); + if (accounts.length === 0) { + throw new Error('No accounts available'); + } + const account = keyring.getAccount(accounts[0] as Address); + if (!account) { + throw new Error('Account not found'); + } + return signTypedData({ account, typedData }); + }, + + async signMessage(message: string, from?: Address): Promise { + if (!keyring) { + throw new Error('Keyring not initialized'); + } + const accounts = keyring.getAccounts(); + const address = from ?? accounts[0]; + if (!address) { + throw new Error('No accounts available'); + } + const account = keyring.getAccount(address); + if (!account) { + throw new Error(`No key for account ${address}`); + } + return signMessage({ account, message }); + }, + }); +} diff --git a/packages/eth-wallet/src/vats/provider-vat.test.ts b/packages/eth-wallet/src/vats/provider-vat.test.ts new file mode 100644 index 000000000..afd9f7254 --- /dev/null +++ b/packages/eth-wallet/src/vats/provider-vat.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { buildRootObject } from './provider-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; +import type { Address, Hex } from '../types.ts'; + +const mockProvider = { + request: vi.fn(), + broadcastTransaction: vi.fn(), + getBalance: vi.fn(), + getChainId: vi.fn(), + getNonce: vi.fn(), +}; + +vi.mock('../lib/provider.ts', () => ({ + makeProvider: vi.fn(() => mockProvider), +})); + +describe('provider-vat', () => { + let baggage: ReturnType; + let root: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + baggage = makeMockBaggage(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + root = buildRootObject({}, undefined, baggage as any); + }); + + describe('bootstrap', () => { + it('completes without error', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (root as any).bootstrap()).toBeUndefined(); + }); + }); + + describe('configure', () => { + it('configures the provider with chain config', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + expect(baggage.has('chainConfig')).toBe(true); + }); + }); + + describe('request', () => { + it('throws when provider is not configured', async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (root as any).request('eth_chainId'), + ).rejects.toThrow('Provider not configured'); + }); + + it('forwards JSON-RPC calls after configuration', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + mockProvider.request.mockResolvedValue('0x1'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (root as any).request('eth_chainId'); + expect(result).toBe('0x1'); + }); + }); + + describe('broadcastTransaction', () => { + it('sends a raw transaction', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + const txHash = '0xabc123' as Hex; + mockProvider.broadcastTransaction.mockResolvedValue(txHash); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (root as any).broadcastTransaction('0xf86c...'); + expect(result).toBe(txHash); + }); + }); + + describe('getBalance', () => { + it('returns balance', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + mockProvider.getBalance.mockResolvedValue('0xde0b6b3a7640000'); + const balance = await ( + root as { getBalance: (a: Address) => Promise } + ).getBalance('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'); + expect(balance).toBe('0xde0b6b3a7640000'); + }); + }); + + describe('getChainId', () => { + it('returns chain ID', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + mockProvider.getChainId.mockResolvedValue(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chainId = await (root as any).getChainId(); + expect(chainId).toBe(1); + }); + }); + + describe('getNonce', () => { + it('returns nonce', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (root as any).configure({ + chainId: 1, + rpcUrl: 'https://eth.example.com', + }); + + mockProvider.getNonce.mockResolvedValue(42); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nonce = await (root as any).getNonce( + '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + ); + expect(nonce).toBe(42); + }); + }); +}); diff --git a/packages/eth-wallet/src/vats/provider-vat.ts b/packages/eth-wallet/src/vats/provider-vat.ts new file mode 100644 index 000000000..dd22fcffe --- /dev/null +++ b/packages/eth-wallet/src/vats/provider-vat.ts @@ -0,0 +1,87 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import { makeProvider } from '../lib/provider.ts'; +import type { Provider } from '../lib/provider.ts'; +import type { Address, ChainConfig, Hex } from '../types.ts'; + +/** + * Vat powers for the provider vat. + */ +type VatPowers = Record; + +/** + * Build the root object for the provider vat. + * + * The provider vat handles all Ethereum JSON-RPC communication. + * It wraps the lib/provider module in an exo interface. + * + * @param _vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters. + * @param baggage - Root of vat's persistent state. + * @returns The root object for the provider vat. + */ +export function buildRootObject( + _vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + let provider: Provider | undefined; + + // Restore provider from persisted chain config + if (baggage.has('chainConfig')) { + const chainConfig = baggage.get('chainConfig') as ChainConfig; + provider = makeProvider(chainConfig); + } + + return makeDefaultExo('walletProvider', { + async bootstrap(): Promise { + // No services needed for the provider vat + }, + + async configure(chainConfig: ChainConfig): Promise { + provider = makeProvider(chainConfig); + + if (baggage.has('chainConfig')) { + baggage.set('chainConfig', chainConfig); + } else { + baggage.init('chainConfig', chainConfig); + } + }, + + async request(method: string, params?: unknown[]): Promise { + if (!provider) { + throw new Error('Provider not configured'); + } + return provider.request(method, params); + }, + + async broadcastTransaction(signedTx: Hex): Promise { + if (!provider) { + throw new Error('Provider not configured'); + } + return provider.broadcastTransaction(signedTx); + }, + + async getBalance(address: Address): Promise { + if (!provider) { + throw new Error('Provider not configured'); + } + return provider.getBalance(address); + }, + + async getChainId(): Promise { + if (!provider) { + throw new Error('Provider not configured'); + } + return provider.getChainId(); + }, + + async getNonce(address: Address): Promise { + if (!provider) { + throw new Error('Provider not configured'); + } + return provider.getNonce(address); + }, + }); +} diff --git a/packages/eth-wallet/test/helpers.ts b/packages/eth-wallet/test/helpers.ts new file mode 100644 index 000000000..0119ec850 --- /dev/null +++ b/packages/eth-wallet/test/helpers.ts @@ -0,0 +1,18 @@ +/** + * Create a mock baggage store for testing. + * + * @returns A mock baggage with Map semantics and an `init` method. + */ +export function makeMockBaggage(): Map & { + init: (key: string, value: unknown) => void; +} { + const store = new Map(); + return Object.assign(store, { + init(key: string, value: unknown) { + if (store.has(key)) { + throw new Error(`Key already exists: ${key}`); + } + store.set(key, value); + }, + }); +} diff --git a/packages/eth-wallet/tsconfig.build.json b/packages/eth-wallet/tsconfig.build.json new file mode 100644 index 000000000..30d7991d1 --- /dev/null +++ b/packages/eth-wallet/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "references": [], + "files": [], + "include": ["./src"] +} diff --git a/packages/eth-wallet/tsconfig.json b/packages/eth-wallet/tsconfig.json new file mode 100644 index 000000000..97ec081eb --- /dev/null +++ b/packages/eth-wallet/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest"] + }, + "references": [ + { "path": "../repo-tools" }, + { "path": "../kernel-utils" }, + { "path": "../logger" }, + { "path": "../ocap-kernel" } + ], + "include": ["../../vitest.config.ts", "./src", "./test", "./vitest.config.ts"] +} diff --git a/packages/eth-wallet/vitest.config.ts b/packages/eth-wallet/vitest.config.ts new file mode 100644 index 000000000..7e1d9d06e --- /dev/null +++ b/packages/eth-wallet/vitest.config.ts @@ -0,0 +1,22 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'eth-wallet', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }), + ); +}); diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index 98ae4f51d..9dad8ac4b 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -273,7 +273,7 @@ export class VatSupervisor { // XXX TODO: this check can and should go away once we can handle `bundleName` and `sourceSpec` too if (!('bundleSpec' in vatConfig)) { throw Error( - 'for now, only sourceSpec is support in vatConfig specifications', + 'for now, only bundleSpec is supported in vatConfig specifications', ); } this.#loaded = true; diff --git a/tsconfig.json b/tsconfig.json index c7c624374..6fb6b852b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "references": [ { "path": "./packages/cli" }, { "path": "./packages/create-package" }, + { "path": "./packages/eth-wallet" }, { "path": "./packages/extension" }, { "path": "./packages/kernel-agents" }, { "path": "./packages/kernel-browser-runtime" }, diff --git a/yarn.lock b/yarn.lock index cc2c6e1c8..08e96b035 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,6 +19,13 @@ __metadata: languageName: node linkType: hard +"@adraffy/ens-normalize@npm:^1.11.0": + version: 1.11.1 + resolution: "@adraffy/ens-normalize@npm:1.11.1" + checksum: 10/dd19274d9fcaf99bf08a62b64e54f4748de11b235767addbd3f7385ae1b7777bd704d17ff003ffaa3295a0b9d035929381cf3b38329c96260bff96aab8ad7b37 + languageName: node + linkType: hard + "@agoric/base-zone@npm:0.1.1-u21.0.1": version: 0.1.1-u21.0.1 resolution: "@agoric/base-zone@npm:0.1.1-u21.0.1" @@ -3193,7 +3200,7 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:^1.1.3": +"@noble/ciphers@npm:^1.1.3, @noble/ciphers@npm:^1.3.0": version: 1.3.0 resolution: "@noble/ciphers@npm:1.3.0" checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 @@ -3209,7 +3216,16 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.1": +"@noble/curves@npm:1.9.1": + version: 1.9.1 + resolution: "@noble/curves@npm:1.9.1" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/5c82ec828ca4a4218b1666ba0ddffde17afd224d0bd5e07b64c2a0c83a3362483387f55c11cfd8db0fc046605394fe4e2c67fe024628a713e864acb541a7d2bb + languageName: node + linkType: hard + +"@noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.1, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -3234,7 +3250,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.8.0": +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.8.0, @noble/hashes@npm:~1.8.0": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e @@ -3542,6 +3558,47 @@ __metadata: languageName: unknown linkType: soft +"@ocap/eth-wallet@workspace:packages/eth-wallet": + version: 0.0.0-use.local + resolution: "@ocap/eth-wallet@workspace:packages/eth-wallet" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" + "@metamask/auto-changelog": "npm:^5.3.0" + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-utils": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.5" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + turbo: "npm:^2.5.6" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + viem: "npm:^2.27.0" + vite: "npm:^7.3.0" + vitest: "npm:^4.0.16" + languageName: unknown + linkType: soft + "@ocap/extension@workspace:packages/extension": version: 0.0.0-use.local resolution: "@ocap/extension@workspace:packages/extension" @@ -4954,10 +5011,10 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3": - version: 1.2.5 - resolution: "@scure/base@npm:1.2.5" - checksum: 10/9a963a27424a373b62760c9ae7099ae496be67eb5b31205639f529f0dbcb2228a827222a36d22842cc2acda78e300a3430d46d84de5d8d4b791208955360853e +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": + version: 1.2.6 + resolution: "@scure/base@npm:1.2.6" + checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 languageName: node linkType: hard @@ -4979,6 +5036,17 @@ __metadata: languageName: node linkType: hard +"@scure/bip32@npm:1.7.0, @scure/bip32@npm:^1.7.0": + version: 1.7.0 + resolution: "@scure/bip32@npm:1.7.0" + dependencies: + "@noble/curves": "npm:~1.9.0" + "@noble/hashes": "npm:~1.8.0" + "@scure/base": "npm:~1.2.5" + checksum: 10/f90e0c23ab6a31a164856ae9cb9a8cae2886df608c74a6c0c4875095b017e30ffd92f28f73b8c52890d9a89fca86d19f6d60bb1ea7cad64c7987f92ae83509ad + languageName: node + linkType: hard + "@scure/bip39@npm:1.3.0": version: 1.3.0 resolution: "@scure/bip39@npm:1.3.0" @@ -4989,6 +5057,16 @@ __metadata: languageName: node linkType: hard +"@scure/bip39@npm:1.6.0, @scure/bip39@npm:^1.6.0": + version: 1.6.0 + resolution: "@scure/bip39@npm:1.6.0" + dependencies: + "@noble/hashes": "npm:~1.8.0" + "@scure/base": "npm:~1.2.5" + checksum: 10/63e60c40fa1bda2c1b50351546fee6d7b0947cc814aa7a4209dcedd3693b5053302c8fca28292f5f50735e11c613265359acdc019127393dbab17e53489fc449 + languageName: node + linkType: hard + "@scure/bip39@npm:^2.0.1": version: 2.0.1 resolution: "@scure/bip39@npm:2.0.1" @@ -6326,6 +6404,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:1.2.3, abitype@npm:^1.2.3": + version: 1.2.3 + resolution: "abitype@npm:1.2.3" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/94e744c2fc301b1cff59163a21b499aae0ddecdf4d3bef1579ff16b705e6f5738fd314125d791ed142487db2473d4fadcdbabb1e05e4b5d35715bc4ef35e400a + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -8890,7 +8983,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": +"eventemitter3@npm:5.0.1, eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" checksum: 10/ac6423ec31124629c84c7077eed1e6987f6d66c31cf43c6fcbf6c87791d56317ce808d9ead483652436df171b526fc7220eccdc9f3225df334e81582c3cf7dd5 @@ -10428,6 +10521,15 @@ __metadata: languageName: node linkType: hard +"isows@npm:1.0.7": + version: 1.0.7 + resolution: "isows@npm:1.0.7" + peerDependencies: + ws: "*" + checksum: 10/044b949b369872882af07b60b613b5801ae01b01a23b5b72b78af80c8103bbeed38352c3e8ceff13a7834bc91fd2eb41cf91ec01d59a041d8705680e6b0ec546 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -12187,6 +12289,27 @@ __metadata: languageName: node linkType: hard +"ox@npm:0.12.1": + version: 0.12.1 + resolution: "ox@npm:0.12.1" + dependencies: + "@adraffy/ens-normalize": "npm:^1.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/bip32": "npm:^1.7.0" + "@scure/bip39": "npm:^1.6.0" + abitype: "npm:^1.2.3" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/ccad93092117ebcc9bb20d875d30b4394b58dce66be63cfa13e8ddccf07471c4598bf6cb50f1dc0b58fb7ab609e75277aeb310fdfee6333f473014714f7149ad + languageName: node + linkType: hard + "p-defer@npm:^4.0.0, p-defer@npm:^4.0.1": version: 4.0.1 resolution: "p-defer@npm:4.0.1" @@ -15164,6 +15287,27 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.27.0": + version: 2.46.1 + resolution: "viem@npm:2.46.1" + dependencies: + "@noble/curves": "npm:1.9.1" + "@noble/hashes": "npm:1.8.0" + "@scure/bip32": "npm:1.7.0" + "@scure/bip39": "npm:1.6.0" + abitype: "npm:1.2.3" + isows: "npm:1.0.7" + ox: "npm:0.12.1" + ws: "npm:8.18.3" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/d6fc96543a7cd84995b244bc64054e625929806e426d2a4ab96cea32d139b3b7f21da3afcdfa01503e0555c571a2af9eaed98045634843e35c0dd9fdab886529 + languageName: node + linkType: hard + "vite-plugin-checker@npm:^0.9.1": version: 0.9.1 resolution: "vite-plugin-checker@npm:0.9.1" @@ -15661,7 +15805,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.2, ws@npm:^8.18.3, ws@npm:^8.4.0": +"ws@npm:8.18.3, ws@npm:^8.18.2, ws@npm:^8.18.3, ws@npm:^8.4.0": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: