diff --git a/.agents/plans/configurable-accounts-tempo-sync.md b/.agents/plans/configurable-accounts-tempo-sync.md new file mode 100644 index 000000000..b0d4e98a4 --- /dev/null +++ b/.agents/plans/configurable-accounts-tempo-sync.md @@ -0,0 +1,202 @@ +# Plan: sync ox configurable accounts with Tempo main + +Status: in progress · Owner: jxom · Last reviewed: 2026-10-20 + +Progress: +- Phases 1, 2, 3 and 4b are done. Phase 2 shipped as `AccountConfig.fromUpgrade` only; the salt is + available as `fromUpgrade(...).salt`, so a separate `getUpgradeSalt` was not added. +- Phase 4a is done: `account` is no longer required for configurable key grants, and no new + requirement was added for `isAdmin` grants, since viem's root-signed admin grants omit it and + TIP-1049 allows that. +- Phase 3 is reversed. The node on `joshie/configurable-t14-devnet-checkpoint` still allows admin keys at + T14, including configurable accounts as admin delegates and as admin-grant authorizers (test + `configurable_account_admin_can_sign_inline_grant`), and has no TIP-1137 code. ox no longer rejects + those grants. Revisit if TIP-1137 lands in the node. +- Phase 5: the e2e runs against `sha-04e67d3`, built from `liam/t14-checkpoint-recovery-factory` + (the devnet checkpoint plus a recovery-factory genesis option). +- Phase 6 wire names are done: the key type is `configurableAccount` and the request field is + `configurableAccountSimulation`; the simulation spec gained the optional grant `signer`. +- Not done, from the checkpoint audit: ox still accepts a keychain nested in a keychain and a + configurable signature in a Tempo authorization list, which the node rejects or skips. Both + predate this work. + +Bring `ox/tempo` configurable-account APIs up to date with Tempo main and the +current TIP cluster, and add TIP-1113 in-place migration helpers. viem depends +on this work (`ox@0.14.56`), so ox goes first and every fix ships on both +`main` (1.x) and `0.x`. + +## Sources of truth + +Newest first. When they disagree, prefer the spec and main over the open +implementation PRs. + +| Source | What it settles | +| --- | --- | +| [tempoxyz/tempo#7510](https://github.com/tempoxyz/tempo/pull/7510) (TIP-1107–1114) | Spec: domains, encodings, migration, T14 gate | +| Tempo main `20c4412` ([#8502](https://github.com/tempoxyz/tempo/pull/8502), [#8497](https://github.com/tempoxyz/tempo/pull/8497)) | Rust constants and test vectors under the new names | +| `crates/primitives/src/transaction/configurable_account/mod.rs` | Domain constants, limits, `CONFIGURABLE_ACCOUNT_UPGRADE_DOMAIN` | +| `crates/primitives/tests/configurable_account.rs` (`configurable_account_hashes_follow_vectors_and_documented_layout`) | Byte-exact vectors to pin in ox tests | +| `crates/contracts/src/precompiles/configurable_account.rs` | `IConfigurableAccount` ABI, including `upgradeAccount` / `AccountUpgraded` | +| [tempoxyz/tempo#8204](https://github.com/tempoxyz/tempo/pull/8204) (TIP-1137) | Admin access keys removed at T14 | +| Open impl PRs [#7577](https://github.com/tempoxyz/tempo/pull/7577), [#7579](https://github.com/tempoxyz/tempo/pull/7579), [#7581](https://github.com/tempoxyz/tempo/pull/7581), [#7894](https://github.com/tempoxyz/tempo/pull/7894), [#8183](https://github.com/tempoxyz/tempo/pull/8183) | RPC wire names. These still use "multisig" naming and have not been rebased onto #8502. | +| [#8486](https://github.com/tempoxyz/tempo/pull/8486) | The precompile. Stacked on #8502, so it uses the new domains, but it does not accept configurable signatures in transactions. | + +## Current state + +- ox was last synced in `42a84e6` (#508, Oct 12). That predates Tempo's + domain and ABI renames (Oct 20). +- The byte layouts, limits, `0x05` envelope, CREATE2 derivation and init-code + hash all match main. The domain strings are the only hashing difference. +- ox has no TIP-1113 migration support. + +## Phase 1: hashing domains (breaking) + +`src/tempo/AccountConfig.ts:36,39,46` uses `tempo:multisig:{account,config,signature}`. +Main uses `tempo:configurable-account:{account,config,signature}`. Every +`getAddress`, `getCommitment`, `getSignPayload` and `AccountOperation.getHash` +result is wrong against the node. + +Tasks: +- Rename `accountDomain`, `configDomain` and `signatureDomain` to the + `tempo:configurable-account:*` strings. +- Update the TSDoc preimage descriptions at `AccountConfig.ts:439` and `:490`. +- Rename `recoveryWalletInitCodeHash` (`:42`) to `accountProxyInitCodeHash`. Keep + the value `0x583cc6…`, and note that it is a placeholder until TIP-1110 + fixes the canonical account proxy. +- Replace the commitment, address and digest snapshots in `AccountConfig.test.ts` + and `AccountOperation.test.ts`. Also add a test that pins the Rust vectors + (factory `0x99…99`, owner `0x11…11`, weight 1, threshold 1, zero salt): + - account salt `0x641ad6eccc3adf35fac828d5cbb727e2489c904a905420e794e8275b6150bfe1` + - address `0x87c346a9029d675f296db533b12e146439cf20d5` + - v0 commitment `0x79cc3a5fcbe18cb8cbbd2b41393750ccf3bbc20533a846755767fa10068ae01d` + - v0 digest (inner `0x42…42`, account `0x91847576f406d0842ad7c1a0c97c22a122e64278`) + `0xe6fa513b5ab32da6f116443f13191096bce77b4850f4d51f3aa436fa7650c964` + - v1 digest `0x285c850cb2803beb10f2f212dac73784a1a03b3991ba79081b24bfa91beb9182` + - v1 commitment `0xee570a0f26f17b41cdf0952a04ee01ea23495b0c0a59f9c904b0d60cb76453c3` + - 48 owners of weight 1, threshold 8 → `0x7a4279bb3f1b8b54d703484317f64eae6814e827` +- Add a changeset. Use `patch`, following #508's precedent for breaking + configurable-account changes. It must say that addresses, commitments and + approval digests change. + +Done when: the new vector test passes, and +`pnpm test --project tempo-unit --bail=1` and `pnpm check:types` are green. + +## Phase 2: TIP-1113 migration helpers (new API) + +Migration needs no new signature format. The root key signs an ordinary `0x76` +transaction whose only call is +`upgradeAccount(uint8 threshold, (address owner, uint8 weight)[] owners)` on +`0xAACC000000000000000000000000000000000000`. The resulting account config is +`{ salt: keccak256("tempo:configurable-account:upgrade" || account), version: 1, threshold, owners }`. + +Tasks in `src/tempo/AccountConfig.ts`: +- Add a private `upgradeDomain = 'tempo:configurable-account:upgrade'`. +- Add `getUpgradeSalt(account)`, which returns `keccak256(upgradeDomain || account20)`. +- Add `fromUpgrade(account, { owners, threshold })`, which returns + `from({ salt: getUpgradeSalt(account), version: 1n, owners, threshold })` + with owners sorted and validated. The root may appear as an owner, because + self-ownership is only rejected at version 0. +- Add TSDoc examples. Add tests that cross-check the salt and commitment + against #8183's node test `crates/node/tests/it/tempo_transaction/multisig/migration.rs`, + after re-deriving them with the new domains. + +Optional in `src/tempo/TxEnvelopeTempo.ts` or `TransactionRequest.ts`: +- Add `assertUpgrade(envelope)`, which checks the transaction is valid for + migration: exactly one call to `0xAACC…` with selector `0x5f5fdc31`, no + `keyAuthorization`, an empty `authorizationList`, and, if signed, a + primitive signature (`secp256k1`, `p256` or `webAuthn`). A fee payer is + allowed. + +Out of scope for ox: the ABI and the actions. Those belong in viem. + +Done when: the new exports have TSDoc and tests, `pnpm zile examples:check` +passes, and a changeset exists (`patch`, following #508, which added +`AccountConfig.update` as a patch). + +## Phase 3: TIP-1137 admin access keys + +At T14, `is_admin = true` and admin-signed `key_authorization` are invalid. +Configurable accounts only exist from T14, so they can never use admin keys. + +Tasks: +- `KeyAuthorization.ts:50,116`: document `isAdmin` as removed from T14 per + TIP-1137. Do not tag it `@deprecated`: admin keys are still valid before + T14, and TIP-1137 is a draft. Leave encode and decode in place for pre-T14 + chains. +- Add a check that rejects `isAdmin: true` when the authorizing account is + configurable, wherever ox can tell (a sign payload with a configurable + `account` binding). + +## Phase 4: correctness nits against the node + +- **Configurable grants without `account`:** `KeyAuthorization.ts:1218-1223` + (reached from `deserialize`) and zod `KeyAuthorization.ts:50` throw + `MissingAccountError` for a configurable `keyType` with no `account`. The + node only checks the binding when one is present, so ox should accept these + grants. Checked against #7894: the node requires `account` only for + admin-signed grants (signer != caller), never for a configurable key type. + Not changed yet, because relaxing it also drops a signing-side guard. + Decide whether to relax decoding only, or both directions. +- **Simulation `keyData`:** `AccountSimulation.ts:170-174` (`shimKeyData`) passes + any value up to 4 bytes through unchanged. The node rejects owner-approval + hints that are not 1, 2 or 4 bytes. Throw `InvalidSimulationError` for 0 + or 3 bytes. + +## Phase 5: e2e infrastructure + +- `test/tempo/setup.global.configurableAccounts.ts:19`: rename the genesis key + `multisigRecoveryFactory` to `configurableAccountRecoveryFactory`. +- `test/tempo/configurableAccounts.genesis.json`: add `t14Time`. +- `test/tempo/configurableAccounts.ts:3`: replace image `sha-83f3ccd` with a + build that has the new domains and T14 integration. + +**Blocked:** neither main nor #8486 accepts configurable-account signatures +in transactions yet. The only integration branches, #7581 and #7894, still +use the old domains. Until one of them rebases onto #8502, the e2e suite cannot +pass with Phase 1 applied. Either skip the configurable e2e project in CI +with a link to the tracking PR, or build a local image from a rebased +integration branch. Do not reintroduce the old domains to keep e2e green. + +## Phase 6: deferred until the Tempo PRs rebase + +Track these. Do not change them before the node does. + +- **RPC key type name:** ox maps `configurable` to the wire value `'multisig'` + (`TransactionRequest.ts:73-75,150,295`; `KeyAuthorization.ts:119-120,554,1011`; + zod `TransactionRequest.ts:29-34`, `KeyAuthorization.ts:40,50`). TIP-1111 + names it `ConfigurableAccount = 3`, and #8502 says there will be no aliases. + Expect the wire value to become `configurableAccount`. When it does, decide + whether to accept both values for one release. +- **Simulation field:** `multisigSimulation` is likely to become + `configurableAccountSimulation` or `accountSimulation`. +- **Account proxy hash:** the init-code hash will change when TIP-1110 is + final, which changes every derived address again. +- **Reserved-address checks:** TIP-1110 also rejects derived addresses that are + virtual addresses, active precompiles, TIP-20 or ZonePortal addresses. Main + leaves this to callers, so it is low priority. Consider an + `AccountConfig.getAddress` option or a separate `assertAddress`. +- **TIP-1108 account `extension`:** add an optional `extension` + (`0x00 || configHash`, 33 bytes) to the `eth_getAccount` / + `eth_getAccountInfo` schemas once the RPC lands. +- **TIP-1142 passport signers:** owner approvals may need to accept the + TIP-1131 ZK signature type (`SignatureEnvelope.ts:735-736`). + +## Release and backport + +1. Land Phases 1–4 on `main` (1.x) in one PR, or as Phase 1 followed by + Phases 2–4. +2. Cherry-pick the same commits onto `0.x` in a `jxom/-v0` branch + (following the existing `*-v0` convention) and release `0.14.57`. +3. Hand off to the viem plan (`viem/.agents/plans/configurable-accounts-tempo-sync.md`), + which bumps to that release. + +## Open questions + +- Activation: #7510 says T14. #8182 and #8183 add a separate + `accountMigrationTime` and a `MigrationNotActive` error. ox should not encode + availability. Confirm which one Tempo keeps. +- Wire names: confirm the final `keyType` and simulation field names with + joshieDo before Phase 6. +- Whether ox should ship the `IConfigurableAccount` ABI and address constant, + or leave them to viem (today they only appear inline in + `configurableAccounts.e2e.test.ts`). diff --git a/.changeset/tempo-account-config-from-upgrade.md b/.changeset/tempo-account-config-from-upgrade.md new file mode 100644 index 000000000..73b0a4717 --- /dev/null +++ b/.changeset/tempo-account-config-from-upgrade.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added `AccountConfig.fromUpgrade` for deriving the version-one config an account installs when it migrates in place with `upgradeAccount` (TIP-1113). diff --git a/.changeset/tempo-account-simulation-key-data.md b/.changeset/tempo-account-simulation-key-data.md new file mode 100644 index 000000000..1f829ae25 --- /dev/null +++ b/.changeset/tempo-account-simulation-key-data.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Fixed `AccountSimulation.toRpc` passing through empty and 3-byte `keyData`, which the Tempo node rejects, by shimming them into a 2-byte length hint. diff --git a/.changeset/tempo-configurable-account-domains.md b/.changeset/tempo-configurable-account-domains.md new file mode 100644 index 000000000..f352a1770 --- /dev/null +++ b/.changeset/tempo-configurable-account-domains.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Renamed the Tempo configurable account hash domains from `tempo:multisig:*` to `tempo:configurable-account:*` to match Tempo main (tempoxyz/tempo#8502), which changes every `AccountConfig.getAddress`, `AccountConfig.getCommitment`, and `AccountConfig.getSignPayload` result. diff --git a/.changeset/tempo-configurable-account-wire-names.md b/.changeset/tempo-configurable-account-wire-names.md new file mode 100644 index 000000000..a9a216500 --- /dev/null +++ b/.changeset/tempo-configurable-account-wire-names.md @@ -0,0 +1,10 @@ +--- +"ox": patch +--- + +Renamed the configurable account RPC key type from `multisig` to `configurableAccount` and `TransactionRequest.multisigSimulation` to `configurableAccountSimulation`, and added the optional `AccountSimulation` grant `signer`, to match the Tempo node. + +```diff +- { keyType: 'multisig', multisigSimulation: spec } ++ { keyType: 'configurableAccount', configurableAccountSimulation: spec } +``` diff --git a/.changeset/tempo-key-authorization-unbound-configurable.md b/.changeset/tempo-key-authorization-unbound-configurable.md new file mode 100644 index 000000000..4921388ac --- /dev/null +++ b/.changeset/tempo-key-authorization-unbound-configurable.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Stopped requiring an `account` binding on configurable account key grants, so `KeyAuthorization` and its zod schemas accept every grant the Tempo node accepts, and deprecated `KeyAuthorization.MissingAccountError`, which is no longer thrown. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1412d8fb1..6447a0a0c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -72,7 +72,8 @@ jobs: test-browser: name: Test Runtime (browser) - runs-on: ubuntu-latest + # Playwright does not support Ubuntu 26.04, which `ubuntu-latest` now resolves to. + runs-on: ubuntu-24.04 steps: - name: Clone repository diff --git a/AGENTS.md b/AGENTS.md index 3ad8596dc..d6259893c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ - **Trusted setup artifacts are generated** -- `src/trusted-setups/internal/setups/mainnet.json` is canonical. Refresh it from an explicit `c-kzg-4844` release with `pnpm trusted-setups:update `; `pnpm trusted-setups:check` verifies its recorded hash and packed TypeScript artifact. See `src/trusted-setups/README.md`. - **KZG WASM disables LTO** -- full LTO makes valid c-kzg trusted-setup initialization trap. Preserve `-fno-lto` and the KZG conformance tests. - **Secrets are local** -- `.env` is local. Do not print, rewrite, or commit secrets. -- **Tempo configurable accounts** -- TIP-1107 accounts are modeled by `AccountConfig` and the `configurable` signature type. Every configurable signature carries `[account, config, primitive approvals]`; config includes a uint64 version. RPC uses the RLP bytes without the `0x05` prefix. Account derivation requires an explicit recovery factory; configurable delegates require keychain V2. The relay layer is configurable-account scoped too (`AccountOperation`, the `account_` RPC namespace), since every configurable transaction carries its config. Protocol and node names stay `multisig` (the `tempo:multisig:*` hash domains, the node's `multisig` key type, and `multisigSimulation`), so map them at the RPC boundary instead of renaming them. +- **Tempo configurable accounts** -- TIP-1107 accounts are modeled by `AccountConfig` and the `configurable` signature type. Every configurable signature carries `[account, config, primitive approvals]`; config includes a uint64 version. RPC uses the RLP bytes without the `0x05` prefix. Account derivation requires an explicit recovery factory; configurable delegates require keychain V2. The relay layer is configurable-account scoped too (`AccountOperation`, the `account_` RPC namespace), since every configurable transaction carries its config. Hash domains follow Tempo main (`tempo:configurable-account:*`, renamed in tempoxyz/tempo#8502). On the wire the key type is `configurableAccount` and the simulation field is `configurableAccountSimulation` (tempoxyz/tempo `joshie/configurable-t14-devnet-checkpoint`); ox maps the `configurable` key type to `configurableAccount` at the RPC boundary. The node allows configurable accounts as admin keys and as admin-key authorizers. - **Zone chain IDs** -- Presto and Moderato use separate fixed bases and ranges. Derive chain IDs as `base + (zoneId % range)`. - **Offline core tests** -- Set `SKIP_GLOBAL_SETUP=1` for focused core tests that do not need Anvil. - **Docgen export comments** -- `extractNamespaceDocComments` should read the nearest JSDoc on an export declaration. ts-morph can include earlier file-level JSDoc descendants on the first export. diff --git a/src/tempo/AccountConfig.test-d.ts b/src/tempo/AccountConfig.test-d.ts index 69e755289..aa3544575 100644 --- a/src/tempo/AccountConfig.test-d.ts +++ b/src/tempo/AccountConfig.test-d.ts @@ -22,6 +22,16 @@ test('from returns a complete configuration', () => { expectTypeOf(zero.version).toEqualTypeOf<0n>() }) +test('fromUpgrade returns a version-one configuration', () => { + const config = AccountConfig.fromUpgrade( + '0x3333333333333333333333333333333333333333', + input, + ) + + expectTypeOf(config).toMatchTypeOf() + expectTypeOf(config.version).toEqualTypeOf<1n>() +}) + test('update returns the next complete configuration', () => { const config = AccountConfig.update(AccountConfig.from(input), { owners: input.owners, diff --git a/src/tempo/AccountConfig.test.ts b/src/tempo/AccountConfig.test.ts index 39e2253d0..143a5e8ef 100644 --- a/src/tempo/AccountConfig.test.ts +++ b/src/tempo/AccountConfig.test.ts @@ -12,6 +12,13 @@ const config = AccountConfig.from({ }) const account = AccountConfig.getAddress(config, { factory }) +// Vectors from Tempo `crates/primitives/tests/configurable_account.rs` +// (`configurable_account_hashes_follow_vectors_and_documented_layout`). +const tempo = { + account: '0x91847576f406d0842ad7c1a0c97c22a122e64278', + factory: '0x9999999999999999999999999999999999999999', +} as const + describe('from', () => { test('behavior: normalizes an initial configuration', () => { expect( @@ -98,10 +105,103 @@ describe('fromRpc/toRpc', () => { }) }) +describe('fromUpgrade', () => { + const root = '0x3333333333333333333333333333333333333333' + + test('example: derives the migrated config', () => { + // The root key may also be listed as an owner. + const config = AccountConfig.fromUpgrade(root, { + owners: [ + { owner: root, weight: 1 }, + { owner: owner_1, weight: 1 }, + ], + threshold: 2, + }) + expect({ + commitment: AccountConfig.getCommitment(config), + config, + }).toMatchInlineSnapshot(` + { + "commitment": "0xabca6d8f3a14b99ecaef378042e6a2ffa4f231125517e073cfbd3bcd69ac6ea1", + "config": { + "owners": [ + { + "owner": "0x1111111111111111111111111111111111111111", + "weight": 1, + }, + { + "owner": "0x3333333333333333333333333333333333333333", + "weight": 1, + }, + ], + "salt": "0x42b3dad8fa6c40b50e6dbbbdaaace72d6c04250c16c1e263a52355983a30eb56", + "threshold": 2, + "version": 1n, + }, + } + `) + }) + + test('behavior: binds the salt to the account', () => { + const options = { + owners: [{ owner: owner_1, weight: 1 }], + threshold: 1, + } as const + expect(AccountConfig.fromUpgrade(root, options).salt).not.toBe( + AccountConfig.fromUpgrade(owner_2, options).salt, + ) + }) + + test('error: rejects an invalid account', () => { + expect(() => + AccountConfig.fromUpgrade('0x1234', { + owners: [{ owner: owner_1, weight: 1 }], + threshold: 1, + }), + ).toThrowErrorMatchingInlineSnapshot(` + [Address.InvalidAddressError: Address "0x1234" is invalid. + + Details: Address is not a 20 byte (40 hexadecimal character) value.] + `) + }) + + test('error: rejects an invalid config', () => { + expect(() => + AccountConfig.fromUpgrade(root, { + owners: [{ owner: owner_1, weight: 1 }], + threshold: 2, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[AccountConfig.InvalidConfigError: Invalid account config: threshold exceeds total owner weight.]`, + ) + }) +}) + describe('getAddress', () => { + test('example: matches the Tempo vectors', () => { + expect({ + maxOwners: AccountConfig.getAddress( + { + owners: Array.from({ length: AccountConfig.maxOwners }, (_, i) => ({ + owner: Hex.padLeft(Hex.fromNumber(i + 1), 20), + weight: 1, + })), + threshold: 8, + }, + { factory: tempo.factory }, + ), + single: AccountConfig.getAddress(config, { factory: tempo.factory }), + }).toMatchInlineSnapshot(` + { + "maxOwners": "0x7a4279bb3f1b8b54d703484317f64eae6814e827", + "single": "0x87c346a9029d675f296db533b12e146439cf20d5", + } + `) + }) + test('example: matches the frozen CREATE2 vector', () => { expect(account).toMatchInlineSnapshot( - `"0x90ba71cb7534da990a1182cf28c9bf415055e03d"`, + `"0x2318ebe50d1d476ec10854bfd949f96ea7737b68"`, ) expect(account).not.toBe('0x8820d1497eeaf4f68e00b2cfc00a2f3b1dbb00da') }) @@ -119,7 +219,7 @@ describe('getAddress', () => { }, { factory }, ), - ).toMatchInlineSnapshot(`"0x7898a7d1dd96946d2a968de1540ca884a244320c"`) + ).toMatchInlineSnapshot(`"0x1baa996a4f142d99999aa22f1cb1eb97eed2a24a"`) }) test('behavior: is stable and binds the salt', () => { @@ -134,8 +234,8 @@ describe('getAddress', () => { ), }).toMatchInlineSnapshot(` { - "repeated": "0x90ba71cb7534da990a1182cf28c9bf415055e03d", - "salted": "0xa1a8472f4a01772db729d8ebc73fe46120750b83", + "repeated": "0x2318ebe50d1d476ec10854bfd949f96ea7737b68", + "salted": "0x70d2e44032514493b68641ce5182cc878baa51f0", } `) }) @@ -156,6 +256,18 @@ describe('getAddress', () => { }) describe('getCommitment', () => { + test('example: matches the Tempo vectors', () => { + expect({ + current: AccountConfig.getCommitment({ ...config, version: 1n }), + initial: AccountConfig.getCommitment(config), + }).toMatchInlineSnapshot(` + { + "current": "0xee570a0f26f17b41cdf0952a04ee01ea23495b0c0a59f9c904b0d60cb76453c3", + "initial": "0x79cc3a5fcbe18cb8cbbd2b41393750ccf3bbc20533a846755767fa10068ae01d", + } + `) + }) + test('example: matches the frozen initial and current vectors', () => { expect({ current: AccountConfig.getCommitment({ ...config, version: 1n }), @@ -163,20 +275,40 @@ describe('getCommitment', () => { initial: AccountConfig.getCommitment(config), }).toMatchInlineSnapshot(` { - "current": "0x6237ca5930f2265d4fb70a0305dd6ceea4df227053b4a62c304489ede946a2f8", - "currentNumber": "0x6237ca5930f2265d4fb70a0305dd6ceea4df227053b4a62c304489ede946a2f8", - "initial": "0xa9e7d1e2ad25e227a4de5f38f3bba31d854ffc8efec46aaa8649097a516bb4ee", + "current": "0xee570a0f26f17b41cdf0952a04ee01ea23495b0c0a59f9c904b0d60cb76453c3", + "currentNumber": "0xee570a0f26f17b41cdf0952a04ee01ea23495b0c0a59f9c904b0d60cb76453c3", + "initial": "0x79cc3a5fcbe18cb8cbbd2b41393750ccf3bbc20533a846755767fa10068ae01d", } `) }) }) describe('getSignPayload', () => { + test('example: matches the Tempo vectors', () => { + expect({ + current: AccountConfig.getSignPayload({ + account: tempo.account, + config: { version: 1n }, + payload, + }), + initial: AccountConfig.getSignPayload({ + account: tempo.account, + config, + payload, + }), + }).toMatchInlineSnapshot(` + { + "current": "0x285c850cb2803beb10f2f212dac73784a1a03b3991ba79081b24bfa91beb9182", + "initial": "0xe6fa513b5ab32da6f116443f13191096bce77b4850f4d51f3aa436fa7650c964", + } + `) + }) + test('example: matches the frozen version-0 vector', () => { expect( AccountConfig.getSignPayload({ account, config, payload }), ).toMatchInlineSnapshot( - `"0x728efe90611b01c91c2be0da56e7eb8e0d2c274c84f97182e274187dde0b8a20"`, + `"0x32144254ec42f7a37b97e3a843667b527479f933320be553265e6594dc44ea87"`, ) }) @@ -324,8 +456,8 @@ describe('assert/validate', () => { valid: AccountConfig.validate(boundary), }).toMatchInlineSnapshot(` { - "account": "0x6acb25715a2b0cf05c098f753a87d9b38471e99f", - "commitment": "0x0dc47a7ab45ffa21a01bfd115427e26617b5a57d7ccbea57db2fd4537ba96f56", + "account": "0x8400988285be93cba9391d445e473601633e5d62", + "commitment": "0xa9282ca5b73ac63b7e27f49cafa6ab13bd2aa79fc45267cf27342db73150cd46", "rlpHash": "0xbaf0d030add91caaa10815d2e99c942f1e39b0d199216973781adb4fc1af6955", "rlpLength": 1145, "valid": true, @@ -495,6 +627,7 @@ test('exports', () => { "from", "fromRpc", "fromTuple", + "fromUpgrade", "getAddress", "getCommitment", "getSignPayload", diff --git a/src/tempo/AccountConfig.ts b/src/tempo/AccountConfig.ts index 4c4732c2e..ada1114ab 100644 --- a/src/tempo/AccountConfig.ts +++ b/src/tempo/AccountConfig.ts @@ -33,17 +33,25 @@ export const signatureTypeByte = '0x05' as const export const zeroSalt = `0x${'00'.repeat(32)}` as const /** Domain prefix for the configurable account address derivation. */ -const accountDomain = 'tempo:multisig:account' +const accountDomain = 'tempo:configurable-account:account' /** Domain prefix for account configuration commitments. */ -const configDomain = 'tempo:multisig:config' +const configDomain = 'tempo:configurable-account:config' -/** Keccak-256 of the canonical recovery wallet creation code. */ -const recoveryWalletInitCodeHash = +/** + * Keccak-256 of the account proxy creation code. + * + * Placeholder until [TIP-1110](https://tips.sh/1110) fixes the canonical + * account proxy, which will change every derived address. + */ +const accountProxyInitCodeHash = '0x583cc63a2e37f645b43eac911b1a6d6de08b83abdc308c61364edda8cfc3bd37' /** Domain prefix for configurable account owner approvals. */ -const signatureDomain = 'tempo:multisig:signature' +const signatureDomain = 'tempo:configurable-account:signature' + +/** Domain prefix for the salt of an account migrated in place. */ +const upgradeDomain = 'tempo:configurable-account:upgrade' /** * Complete account configuration witness. @@ -347,12 +355,75 @@ export function fromTuple(tuple: Tuple): Config { return config } +/** + * Derives the configuration an existing account installs when it migrates in + * place with the `upgradeAccount` precompile call + * ([TIP-1113](https://tips.sh/1113)). + * + * The account keeps its address. Its salt is + * `keccak256("tempo:configurable-account:upgrade" || account)` and its version + * is `1n`. Owners are sorted into canonical order and the config is validated. + * The account itself may be listed as an owner. + * + * @example + * ```ts twoslash + * import { AccountConfig } from 'ox/tempo' + * + * const config = AccountConfig.fromUpgrade( + * '0x3333333333333333333333333333333333333333', + * { + * owners: [ + * { + * owner: '0x1111111111111111111111111111111111111111', + * weight: 1 + * } + * ], + * threshold: 1 + * } + * ) + * // config.version === 1n + * ``` + * + * @param account - The account migrating in place. + * @param options - The owners and threshold to install. + * @returns The installed account config. + */ +export function fromUpgrade( + account: Address.Address, + options: fromUpgrade.Options, +): Config<1n, numberType> { + Address.assert(account) + return from({ + owners: options.owners, + salt: Hash.keccak256(Hex.concat(Hex.fromString(upgradeDomain), account)), + threshold: options.threshold, + version: 1n, + }) +} + +export declare namespace fromUpgrade { + type Options = { + /** Weighted owners to install. Sorted into canonical order. */ + owners: readonly Owner[] + /** Minimum total owner weight required for authorization. */ + threshold: numberType + } + + type ErrorType = + | Address.assert.ErrorType + | assert.ErrorType + | Hash.keccak256.ErrorType + | Hex.concat.ErrorType + | Hex.fromString.ErrorType + | Errors.GlobalErrorType +} + /** * Derives the stable configurable account address. * * The initial config is hashed into a CREATE2 salt using fixed-width * big-endian fields, not RLP. The account uses the chain-configured recovery - * factory and wallet init-code hash. + * factory and account proxy init-code hash. * * The address is derived once from the initial version-0 config. Config * updates do not change it. @@ -403,7 +474,7 @@ export function getAddress( ), ) const account = ContractAddress.fromCreate2({ - bytecodeHash: recoveryWalletInitCodeHash, + bytecodeHash: accountProxyInitCodeHash, from: options.factory, salt: accountSalt, }) @@ -436,7 +507,7 @@ export declare namespace getAddress { * Computes the commitment for an account configuration. * * The commitment uses raw fixed-width fields, not RLP or ABI encoding: - * `keccak256("tempo:multisig:config" || salt || uint64be(version) || uint8(threshold) || uint8(owners.length) || owners)`. + * `keccak256("tempo:configurable-account:config" || salt || uint64be(version) || uint8(threshold) || uint8(owners.length) || owners)`. * * @example * ```ts twoslash @@ -487,7 +558,7 @@ export declare namespace getCommitment { /** * Computes the digest a configurable account owner approves (signs). * - * `keccak256("tempo:multisig:signature" || inner_digest || account || uint64be(version))`, + * `keccak256("tempo:configurable-account:signature" || inner_digest || account || uint64be(version))`, * where `inner_digest` is the transaction sign payload * ({@link ox#TxEnvelopeTempo.(getSignPayload:function)}). * diff --git a/src/tempo/AccountOperation.test.ts b/src/tempo/AccountOperation.test.ts index 743ae13b6..d3cd4b05e 100644 --- a/src/tempo/AccountOperation.test.ts +++ b/src/tempo/AccountOperation.test.ts @@ -153,8 +153,8 @@ describe('getHash', () => { }), }).toMatchInlineSnapshot(` { - "keyAuthorization": "0xa83016f0e94c4a6125483ffeba3abb0a8df1bc0f5a503f63639e806b793520bb", - "transaction": "0x2d7e3c7c38a9acf85f0a9d70a39b0e9e5a05d92c7a73b633a47a45be4a112b3b", + "keyAuthorization": "0xd18f389ae23bdc81939861c9a1079201968a2ec5fbd67ad1063556b46af8914f", + "transaction": "0x246027f7c6940fb4b68a746c49819c1c45737aa6c975aafcce721b4a619a9ab1", } `) }) @@ -345,14 +345,14 @@ describe('serializeKeyAuthorization', () => { expect(results).toMatchInlineSnapshot(` [ { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", - "hash": "0xcd4283709cc1853dc8ed9cc4dd8c3b6a22e1b01f19ad3f6348f4bebe79590408", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", + "hash": "0x8d72c92f8aca5cf651ff914a928a32b1717e51b70fce97e305aaab02617474f5", "signatureCount": 2, "version": 1n, }, { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", - "hash": "0x1f66e6adc4ddf8ac9d4575f518be11509ca616ac13d4e0a286b4eef7b96959f2", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", + "hash": "0x1dce79ae000bb60526cbe3b18be29d517319ee0958b01e5e3e89608e19c13459", "signatureCount": 2, "version": 0n, }, @@ -446,15 +446,15 @@ describe('serializeTransaction', () => { expect(results).toMatchInlineSnapshot(` [ { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", - "hash": "0x6a034f208339f7eaa4a42b3fa03aad5df77ebe83659dac2e4d2b11226d221928", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", + "hash": "0x75106153400c34efbeb7380828e3fef939340eb0a3a42dda6a22f798f94665d0", "signatureCount": 2, "type": "0x76", "version": 1n, }, { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", - "hash": "0x04d36e54d7a816b080dfa03370da6fbeb3523ce3eff5211d7f22a3c7e87476ee", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", + "hash": "0x2718b956492b1f8ff4e25165ac425e9e7064580b76eb087ce764e263723152bc", "signatureCount": 2, "type": "0x76", "version": 0n, @@ -535,7 +535,7 @@ describe('serializeTransaction', () => { [ { "feePayerSignature": null, - "from": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "from": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "signatureCount": 2, "type": "0x78", }, @@ -545,7 +545,7 @@ describe('serializeTransaction', () => { "s": "0x0000000000000000000000000000000000000000000000000000000000000006", "yParity": 0, }, - "from": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "from": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "signatureCount": 2, "type": "0x78", }, @@ -617,7 +617,7 @@ describe('from', () => { expect({ pending, submitting, success }).toMatchInlineSnapshot(` { "pending": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -637,7 +637,7 @@ describe('from', () => { "version": 1n, }, "createdAt": 1, - "hash": "0x2d7e3c7c38a9acf85f0a9d70a39b0e9e5a05d92c7a73b633a47a45be4a112b3b", + "hash": "0x246027f7c6940fb4b68a746c49819c1c45737aa6c975aafcce721b4a619a9ab1", "signatureCount": 1, "status": "pending", "threshold": 2, @@ -647,7 +647,7 @@ describe('from', () => { "weight": 1, }, "submitting": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", "0x01000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", @@ -669,7 +669,7 @@ describe('from', () => { }, "createdAt": 1, "expiresAt": 10, - "hash": "0x2d7e3c7c38a9acf85f0a9d70a39b0e9e5a05d92c7a73b633a47a45be4a112b3b", + "hash": "0x246027f7c6940fb4b68a746c49819c1c45737aa6c975aafcce721b4a619a9ab1", "signatureCount": 2, "status": "submitting", "submissionId": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -680,7 +680,7 @@ describe('from', () => { "weight": 2, }, "success": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", "0x01000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", @@ -701,7 +701,7 @@ describe('from', () => { "version": 1n, }, "createdAt": 1, - "hash": "0x2d7e3c7c38a9acf85f0a9d70a39b0e9e5a05d92c7a73b633a47a45be4a112b3b", + "hash": "0x246027f7c6940fb4b68a746c49819c1c45737aa6c975aafcce721b4a619a9ab1", "signatureCount": 2, "status": "success", "threshold": 2, @@ -744,10 +744,9 @@ describe('from', () => { { hash: expect.any(String), transaction: expect.stringMatching(/^0x78/), - }, - ` + }, ` { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -776,8 +775,7 @@ describe('from', () => { "updatedAt": 2, "weight": 1, } - `, - ) + `) }) test('fee-payer-signed transaction', () => { @@ -830,10 +828,9 @@ describe('from', () => { { hash: expect.any(String), transaction: expect.any(String), - }, - ` + }, ` { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -862,8 +859,7 @@ describe('from', () => { "updatedAt": 2, "weight": 1, } - `, - ) + `) }) test('key authorization states', () => { @@ -890,7 +886,7 @@ describe('from', () => { expect({ pending, success }).toMatchInlineSnapshot(` { "pending": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -910,8 +906,8 @@ describe('from', () => { "version": 1n, }, "createdAt": 1, - "hash": "0xa83016f0e94c4a6125483ffeba3abb0a8df1bc0f5a503f63639e806b793520bb", - "keyAuthorization": "0xf838f782107980943333333333333333333333333333333333333333846b49d2008080808094ef6b17836e1b3647105c3084124485e3e1b0ab7f", + "hash": "0xd18f389ae23bdc81939861c9a1079201968a2ec5fbd67ad1063556b46af8914f", + "keyAuthorization": "0xf838f782107980943333333333333333333333333333333333333333846b49d20080808080940f8659dd87410f00a1668211ecda7ac7f9c9fd13", "signatureCount": 1, "status": "pending", "threshold": 2, @@ -920,7 +916,7 @@ describe('from', () => { "weight": 1, }, "success": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", "0x01000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", @@ -941,8 +937,8 @@ describe('from', () => { "version": 1n, }, "createdAt": 1, - "hash": "0xa83016f0e94c4a6125483ffeba3abb0a8df1bc0f5a503f63639e806b793520bb", - "keyAuthorization": "0xf901b3f782107980943333333333333333333333333333333333333333846b49d2008080808094ef6b17836e1b3647105c3084124485e3e1b0ab7fb9017805f9017494ef6b17836e1b3647105c3084124485e3e1b0ab7ff852a000000000000000000000000000000000000000000000000000000000000000000102eed69407e1ed8ea0e9601e5546b0a03aed683df360140701d694288f0cd85005f34168f731a468aef268c2f9456f01f90108b88201000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200b88201000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", + "hash": "0xd18f389ae23bdc81939861c9a1079201968a2ec5fbd67ad1063556b46af8914f", + "keyAuthorization": "0xf901b3f782107980943333333333333333333333333333333333333333846b49d20080808080940f8659dd87410f00a1668211ecda7ac7f9c9fd13b9017805f90174940f8659dd87410f00a1668211ecda7ac7f9c9fd13f852a000000000000000000000000000000000000000000000000000000000000000000102eed69407e1ed8ea0e9601e5546b0a03aed683df360140701d694288f0cd85005f34168f731a468aef268c2f9456f01f90108b88201000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200b88201000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", "signatureCount": 2, "status": "success", "threshold": 2, @@ -985,10 +981,9 @@ describe('from', () => { { hash: expect.any(String), keyAuthorization: expect.any(String), - }, - ` + }, ` { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", "0x01000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d100", @@ -1018,8 +1013,7 @@ describe('from', () => { "updatedAt": 2, "weight": 2, } - `, - ) + `) }) }) @@ -1034,7 +1028,7 @@ describe('RPC conversion', () => { expect({ keyAuthorizationRpc, transactionRpc }).toMatchInlineSnapshot(` { "keyAuthorizationRpc": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -1054,8 +1048,8 @@ describe('RPC conversion', () => { "version": "0x1", }, "createdAt": 1, - "hash": "0xa83016f0e94c4a6125483ffeba3abb0a8df1bc0f5a503f63639e806b793520bb", - "keyAuthorization": "0xf838f782107980943333333333333333333333333333333333333333846b49d2008080808094ef6b17836e1b3647105c3084124485e3e1b0ab7f", + "hash": "0xd18f389ae23bdc81939861c9a1079201968a2ec5fbd67ad1063556b46af8914f", + "keyAuthorization": "0xf838f782107980943333333333333333333333333333333333333333846b49d20080808080940f8659dd87410f00a1668211ecda7ac7f9c9fd13", "signatureCount": 1, "status": "pending", "threshold": 2, @@ -1064,7 +1058,7 @@ describe('RPC conversion', () => { "weight": 1, }, "transactionRpc": { - "account": "0xef6b17836e1b3647105c3084124485e3e1b0ab7f", + "account": "0x0f8659dd87410f00a1668211ecda7ac7f9c9fd13", "approvals": [ "0x01000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000065ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d503200", ], @@ -1084,7 +1078,7 @@ describe('RPC conversion', () => { "version": "0x1", }, "createdAt": 1, - "hash": "0x2d7e3c7c38a9acf85f0a9d70a39b0e9e5a05d92c7a73b633a47a45be4a112b3b", + "hash": "0x246027f7c6940fb4b68a746c49819c1c45737aa6c975aafcce721b4a619a9ab1", "signatureCount": 1, "status": "pending", "threshold": 2, diff --git a/src/tempo/AccountSimulation.test.ts b/src/tempo/AccountSimulation.test.ts index 60deca3bd..d06c9b4f5 100644 --- a/src/tempo/AccountSimulation.test.ts +++ b/src/tempo/AccountSimulation.test.ts @@ -113,6 +113,25 @@ describe('fromRpc', () => { }) describe('toRpc', () => { + test('behavior: round-trips the grant signer', () => { + const signer = '0x2222222222222222222222222222222222222222' + const spec = AccountSimulation.fromRpc({ ...rpc, signer }) + expect(spec.signer).toBe(signer) + expect(AccountSimulation.toRpc(spec).signer).toBe(signer) + expect( + 'signer' in AccountSimulation.toRpc(AccountSimulation.fromRpc(rpc)), + ).toBe(false) + }) + + test('error: rejects an invalid grant signer', () => { + expect(() => AccountSimulation.fromRpc({ ...rpc, signer: '0x1234' })) + .toThrowErrorMatchingInlineSnapshot(` + [Address.InvalidAddressError: Address "0x1234" is invalid. + + Details: Address is not a 20 byte (40 hexadecimal character) value.] + `) + }) + test('behavior: encodes configurations and shims long key data', () => { expect(AccountSimulation.toRpc(AccountSimulation.fromRpc(rpc))) .toMatchInlineSnapshot(` @@ -146,6 +165,30 @@ describe('toRpc', () => { `) }) + test('behavior: shims key data that is not a valid length hint', () => { + const keyData = (keyData: `0x${string}`) => + AccountSimulation.toRpc( + AccountSimulation.fromRpc({ + ...rpc, + approvals: [{ ...rpc.approvals[0], keyData }], + }), + ).approvals[0]?.keyData + + expect({ + empty: keyData('0x'), + four: keyData('0x01020304'), + one: keyData('0x01'), + three: keyData('0x010203'), + }).toMatchInlineSnapshot(` + { + "empty": "0x0000", + "four": "0x01020304", + "one": "0x01", + "three": "0x0003", + } + `) + }) + test('behavior: encodes the maximum uint64 configuration version', () => { const spec = AccountSimulation.fromRpc(rpc) diff --git a/src/tempo/AccountSimulation.ts b/src/tempo/AccountSimulation.ts index 8d3712a88..d9cff7b6d 100644 --- a/src/tempo/AccountSimulation.ts +++ b/src/tempo/AccountSimulation.ts @@ -28,6 +28,8 @@ export type Rpc = Compute<{ approvals: readonly ApprovalRpc[] /** Canonical RLP-encoded applicable configuration. */ config: Hex.Hex + /** Account that signs the modeled key authorization. Omitted for a direct signature. */ + signer?: Address.Address | undefined }> /** Account simulation spec used to construct an RPC simulation signature. */ @@ -36,6 +38,8 @@ export type Spec = Compute<{ approvals: readonly Approval[] /** Complete applicable configuration. */ config: AccountConfig.Config + /** Account that signs the modeled key authorization. Omitted for a direct signature. */ + signer?: Address.Address | undefined }> /** @@ -53,14 +57,20 @@ export type Spec = Compute<{ * @returns The account simulation spec with decoded configurations. */ export function fromRpc(spec: Rpc): Spec { - const { approvals, config } = spec + const { approvals, config, signer } = spec assertApprovals(approvals) - return { approvals, config: deserializeConfig(config) } + if (signer !== undefined) Address.assert(signer) + return { + approvals, + config: deserializeConfig(config), + ...(signer !== undefined ? { signer } : {}), + } } export declare namespace fromRpc { /** Error type for `fromRpc`. */ export type ErrorType = + | Address.assert.ErrorType | Hex.isEqual.ErrorType | InvalidSimulationError | AccountConfig.assert.ErrorType @@ -84,8 +94,9 @@ export declare namespace fromRpc { * @returns The JSON-RPC account simulation spec with encoded configurations. */ export function toRpc(spec: Spec): Rpc { - const { approvals, config } = spec + const { approvals, config, signer } = spec assertApprovals(approvals) + if (signer !== undefined) Address.assert(signer) return { approvals: approvals.map(({ keyData, keyType, owner }) => ({ ...(keyData !== undefined ? { keyData: shimKeyData(keyData) } : {}), @@ -93,12 +104,14 @@ export function toRpc(spec: Spec): Rpc { owner, })), config: serializeConfig(config), + ...(signer !== undefined ? { signer } : {}), } } export declare namespace toRpc { /** Error type for `toRpc`. */ export type ErrorType = + | Address.assert.ErrorType | Hex.fromNumber.ErrorType | InvalidSimulationError | AccountConfig.assert.ErrorType @@ -164,12 +177,13 @@ function serializeConfig(config: AccountConfig.Input): Hex.Hex { } /** - * Shims long key data into the length hint accepted by Tempo's gas estimator. + * Shims key data into the 1-, 2-, or 4-byte length hint accepted by Tempo's + * gas estimator. Other sizes are replaced by a 2-byte hint of their length. * @internal */ function shimKeyData(data: Hex.Hex): Hex.Hex { const size = Hex.size(data) - if (size <= 4) return data + if (size === 1 || size === 2 || size === 4) return data return Hex.fromNumber(size, { size: 2 }) } diff --git a/src/tempo/KeyAuthorization.test.ts b/src/tempo/KeyAuthorization.test.ts index a2881f760..7a3e5f3c8 100644 --- a/src/tempo/KeyAuthorization.test.ts +++ b/src/tempo/KeyAuthorization.test.ts @@ -96,42 +96,80 @@ const signature_keychain = { } as const satisfies SignatureEnvelope.Keychain describe('configurable account binding', () => { - test('rejects unbound grants before signing or encoding', () => { - const authorization = { - address, - chainId: 1n, - type: 'configurable', - } as const - const signed = { - ...authorization, - signature: SignatureEnvelope.from(signature_secp256k1), - } - for (const encode of [ - () => KeyAuthorization.from(authorization), - () => KeyAuthorization.toTuple(authorization), - () => KeyAuthorization.getSignPayload(authorization), - () => KeyAuthorization.serialize(authorization), - () => KeyAuthorization.toRpc(signed), - ]) - expect(encode).toThrowErrorMatchingInlineSnapshot( - `[KeyAuthorization.MissingAccountError: Configurable account key grants require a parent account binding.]`, - ) + test('behavior: accepts unbound configurable grants', () => { + // The node binds a root-signed grant to its signer, so only admin-signed + // grants need `account` (TIP-1049). + const signed = KeyAuthorization.from( + { address, chainId: 1n, type: 'configurable' }, + { signature: signature_secp256k1 }, + ) + const rpc = KeyAuthorization.toRpc(signed) + expect({ + deserialized: KeyAuthorization.deserialize( + KeyAuthorization.serialize(signed), + ), + fromRpc: KeyAuthorization.fromRpc(rpc), + fromTuple: KeyAuthorization.fromTuple([['0x01', '0x03', address]]), + rpc, + }).toMatchInlineSnapshot(` + { + "deserialized": { + "address": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "chainId": 1n, + "signature": { + "signature": { + "r": "0xfa78c5905fb0b9d6066ef531f962a62bc6ef0d5eb59ecb134056d206f75aaed7", + "s": "0x780926ff2601a935c2c79707d9e1799948c9f19dcdde1e090e903b19a07923d0", + "yParity": 1, + }, + "type": "secp256k1", + }, + "type": "configurable", + }, + "fromRpc": { + "address": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "chainId": 1n, + "limits": undefined, + "signature": { + "signature": { + "r": "0xfa78c5905fb0b9d6066ef531f962a62bc6ef0d5eb59ecb134056d206f75aaed7", + "s": "0x780926ff2601a935c2c79707d9e1799948c9f19dcdde1e090e903b19a07923d0", + "yParity": 1, + }, + "type": "secp256k1", + }, + "type": "configurable", + }, + "fromTuple": { + "address": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "chainId": 1n, + "type": "configurable", + }, + "rpc": { + "chainId": "0x1", + "expiry": null, + "keyId": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "keyType": "configurableAccount", + "limits": undefined, + "signature": { + "r": "0xfa78c5905fb0b9d6066ef531f962a62bc6ef0d5eb59ecb134056d206f75aaed7", + "s": "0x780926ff2601a935c2c79707d9e1799948c9f19dcdde1e090e903b19a07923d0", + "type": "secp256k1", + "yParity": "0x1", + }, + }, + } + `) }) - test('rejects unbound RPC and RLP grants', () => { + test('behavior: preserves a bound account', () => { const signed = KeyAuthorization.from( { account: address, address, chainId: 1n, type: 'configurable' }, { signature: signature_secp256k1 }, ) - const rpc = KeyAuthorization.toRpc(signed) - for (const account of [undefined, null]) - expect(() => KeyAuthorization.fromRpc({ ...rpc, account })).toThrowError( - KeyAuthorization.MissingAccountError, - ) - expect(() => - KeyAuthorization.fromTuple([['0x01', '0x03', address]]), - ).toThrowError(KeyAuthorization.MissingAccountError) - expect(KeyAuthorization.fromRpc(rpc).account).toBe(address) + expect( + KeyAuthorization.fromRpc(KeyAuthorization.toRpc(signed)).account, + ).toBe(address) expect( KeyAuthorization.deserialize(KeyAuthorization.serialize(signed)).account, ).toBe(address) @@ -1450,13 +1488,13 @@ describe('serialize', () => { }) describe('toRpc', () => { - test('behavior: configurable key type uses the RPC multisig key type', () => { + test('behavior: configurable key type uses the RPC configurableAccount key type', () => { const authorization = KeyAuthorization.from( { account: address, address, chainId: 1n, type: 'configurable' }, { signature: signature_secp256k1 }, ) const rpc = KeyAuthorization.toRpc(authorization) - expect(rpc.keyType).toMatchInlineSnapshot(`"multisig"`) + expect(rpc.keyType).toMatchInlineSnapshot(`"configurableAccount"`) expect(KeyAuthorization.fromRpc(rpc).type).toMatchInlineSnapshot( `"configurable"`, ) @@ -2818,4 +2856,25 @@ describe('admin keys (TIP-1049)', () => { }) expect(KeyAuthorization.hash(a)).not.toBe(KeyAuthorization.hash(b)) }) + + describe('configurable admin keys', () => { + test('behavior: a configurable account may sign or be granted an admin key', () => { + const signed = KeyAuthorization.from( + { + account: signature_configurable.account, + address, + chainId: 1n, + isAdmin: true, + type: 'configurable', + }, + { signature: signature_configurable }, + ) + expect( + KeyAuthorization.deserialize(KeyAuthorization.serialize(signed)), + ).toEqual(signed) + expect( + KeyAuthorization.fromRpc(KeyAuthorization.toRpc(signed)), + ).toMatchObject({ isAdmin: true, type: 'configurable' }) + }) + }) }) diff --git a/src/tempo/KeyAuthorization.ts b/src/tempo/KeyAuthorization.ts index 6aba711bf..317fbdd8e 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -38,7 +38,7 @@ export type KeyAuthorization< bigintType = bigint, numberType = number, > = { - /** Account address this authorization is bound to. Required for configurable account grants. */ + /** Account address this authorization is bound to. Required when an admin access key signs the grant (TIP-1049). */ account?: Address.Address | undefined /** Address derived from the public key of the key type. */ address: Address.Address @@ -116,8 +116,8 @@ export type Rpc = { isAdmin?: boolean | null | undefined /** Key identifier. */ keyId: Address.Address - /** Key type. Configurable accounts use the node's `multisig` key type. */ - keyType: SignatureEnvelope.Type | 'multisig' + /** Key type. Configurable accounts use the node's `configurableAccount` key type. */ + keyType: SignatureEnvelope.Type | 'configurableAccount' /** Token spending limits (`null`/`undefined` = unlimited, `[]` = no spending allowed). */ limits?: readonly RpcTokenLimit[] | null | undefined /** Primitive or configurable account signature authorizing this key. */ @@ -445,7 +445,6 @@ export function from< options: from.Options = {}, ): from.ReturnType { if ('keyId' in authorization) return fromRpc(authorization as Rpc) as never - assertAccountBinding(authorization.type, authorization.account) const auth = authorization as KeyAuthorization & { limits?: readonly { limit: bigint; token: Address.Address }[] scopes?: readonly { @@ -509,10 +508,7 @@ export declare namespace from { : {}) > - type ErrorType = - | InvalidSignatureTypeError - | MissingAccountError - | Errors.GlobalErrorType + type ErrorType = InvalidSignatureTypeError | Errors.GlobalErrorType } /** @@ -551,8 +547,7 @@ export function fromRpc(authorization: Rpc): Signed { const witness = authorization.witness ?? undefined const isAdmin = authorization.isAdmin ?? undefined const account = authorization.account ?? undefined - const type = keyType === 'multisig' ? 'configurable' : keyType - assertAccountBinding(type, account) + const type = keyType === 'configurableAccount' ? 'configurable' : keyType const signature = SignatureEnvelope.fromRpc(authorization.signature) assertSignature(signature) if (witness !== undefined) assertWitness(witness) @@ -595,10 +590,7 @@ export function fromRpc(authorization: Rpc): Signed { } export declare namespace fromRpc { - type ErrorType = - | InvalidSignatureTypeError - | MissingAccountError - | Errors.GlobalErrorType + type ErrorType = InvalidSignatureTypeError | Errors.GlobalErrorType } /** @@ -728,7 +720,6 @@ export function fromTuple( const account = isAbsent(rawAccount) ? undefined : (rawAccount as Address.Address) - assertAccountBinding(keyType, account) const args: KeyAuthorization = { address: keyId, chainId: chainId === '0x' ? 0n : Hex.toBigInt(chainId), @@ -753,10 +744,7 @@ export declare namespace fromTuple { KeyAuthorization ? true : false> > - type ErrorType = - | InvalidSignatureTypeError - | MissingAccountError - | Errors.GlobalErrorType + type ErrorType = InvalidSignatureTypeError | Errors.GlobalErrorType } /** @@ -962,7 +950,6 @@ export declare namespace serialize { * @returns An RPC-formatted Key Authorization. */ export function toRpc(authorization: toRpc.Input): Rpc { - assertAccountBinding(authorization.type, authorization.account) const { address, scopes, @@ -1008,7 +995,7 @@ export function toRpc(authorization: toRpc.Input): Rpc { chainId: chainIdHex === '0x0' ? '0x' : chainIdHex, expiry: expiry != null ? Quantity.fromNumberish(expiry) : null, keyId: address, - keyType: type === 'configurable' ? 'multisig' : type, + keyType: type === 'configurable' ? 'configurableAccount' : type, limits: limits?.map(({ token, limit, period }) => ({ token, limit: Quantity.fromNumberish(limit), @@ -1028,10 +1015,7 @@ export declare namespace toRpc { /** Numberish input accepted by {@link ox#KeyAuthorization.(toRpc:function)}. */ type Input = Signed - type ErrorType = - | InvalidSignatureTypeError - | MissingAccountError - | Errors.GlobalErrorType + type ErrorType = InvalidSignatureTypeError | Errors.GlobalErrorType } /** @@ -1070,7 +1054,6 @@ export declare namespace toRpc { export function toTuple( authorization: authorization, ): toTuple.ReturnType { - assertAccountBinding(authorization.type, authorization.account) const { address, chainId, @@ -1178,10 +1161,7 @@ export declare namespace toTuple { type ReturnType = Compute ? true : false>> - type ErrorType = - | InvalidSignatureTypeError - | MissingAccountError - | Errors.GlobalErrorType + type ErrorType = InvalidSignatureTypeError | Errors.GlobalErrorType } function bigintToHex(value: bigint): Hex.Hex { @@ -1215,14 +1195,6 @@ function resolveSelector( return AbiItem.getSelector(selector) } -function assertAccountBinding( - type: KeyAuthorization['type'], - account: Address.Address | null | undefined, -): void { - if (type === 'configurable' && account == null) - throw new MissingAccountError() -} - function assertWitness(witness: Hex.Hex): void { if (Hex.size(witness) !== 32) throw new InvalidWitnessSizeError(witness) } @@ -1270,7 +1242,12 @@ export class InvalidSignatureTypeError extends Error { } } -/** Thrown when a configurable account key grant omits its parent account binding. */ +/** + * Thrown when a configurable account key grant omits its parent account binding. + * + * @deprecated No longer thrown. The Tempo node only requires `account` on grants + * signed by an admin access key, which it checks against the transaction sender. + */ export class MissingAccountError extends Error { override readonly name = 'KeyAuthorization.MissingAccountError' constructor() { diff --git a/src/tempo/RpcSchemaTempo.test-d.ts b/src/tempo/RpcSchemaTempo.test-d.ts index f427573a2..703dc6046 100644 --- a/src/tempo/RpcSchemaTempo.test-d.ts +++ b/src/tempo/RpcSchemaTempo.test-d.ts @@ -17,7 +17,7 @@ declare const tempoProvider: Provider.Provider<{ declare const address: Address.Address declare const hash: Hex.Hex declare const keyAuthorization: KeyAuthorization.Rpc -declare const multisigSimulation: AccountSimulation.Rpc +declare const configurableAccountSimulation: AccountSimulation.Rpc declare const serializedTransaction: Hex.Hex declare const signature: Hex.Hex @@ -79,7 +79,7 @@ test('tempo simulation accepts account simulation specs', () => { { calls: [ { - multisigSimulation, + configurableAccountSimulation, }, ], }, diff --git a/src/tempo/TransactionRequest.test-d.ts b/src/tempo/TransactionRequest.test-d.ts index d03188cd8..42ed6d789 100644 --- a/src/tempo/TransactionRequest.test-d.ts +++ b/src/tempo/TransactionRequest.test-d.ts @@ -53,19 +53,19 @@ describe('TransactionRequest type', () => { expectTypeOf< TransactionRequest.TransactionRequest['accountSimulation'] >().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< - AccountSimulation.Rpc | undefined - >() + expectTypeOf< + TransactionRequest.Rpc['configurableAccountSimulation'] + >().toEqualTypeOf() }) - test('maps the configurable key type to the RPC multisig key type', () => { + test('maps the configurable key type to the RPC configurableAccount key type', () => { expectTypeOf< TransactionRequest.TransactionRequest['keyType'] >().toEqualTypeOf< 'configurable' | 'p256' | 'secp256k1' | 'webAuthn' | undefined >() expectTypeOf().toEqualTypeOf< - 'multisig' | 'p256' | 'secp256k1' | 'webAuthn' | undefined + 'configurableAccount' | 'p256' | 'secp256k1' | 'webAuthn' | undefined >() }) }) diff --git a/src/tempo/TransactionRequest.test.ts b/src/tempo/TransactionRequest.test.ts index 851837c6b..319ebadec 100644 --- a/src/tempo/TransactionRequest.test.ts +++ b/src/tempo/TransactionRequest.test.ts @@ -139,9 +139,9 @@ describe('fromRpc', () => { `) }) - test('behavior: multisig key type decodes as configurable', () => { + test('behavior: configurableAccount key type decodes as configurable', () => { const request = TransactionRequest.fromRpc({ - keyType: 'multisig', + keyType: 'configurableAccount', type: '0x76', }) expect(request.keyType).toMatchInlineSnapshot(`"configurable"`) @@ -150,7 +150,7 @@ describe('fromRpc', () => { test('behavior: account simulation', () => { const request = TransactionRequest.fromRpc({ from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - multisigSimulation: accountSimulationRpc, + configurableAccountSimulation: accountSimulationRpc, type: '0x76', }) @@ -266,11 +266,11 @@ describe('toRpc', () => { `) }) - test('behavior: configurable key type encodes as multisig', () => { + test('behavior: configurable key type encodes as configurableAccount', () => { const request = TransactionRequest.toRpc({ keyType: 'configurable', }) - expect(request.keyType).toMatchInlineSnapshot(`"multisig"`) + expect(request.keyType).toMatchInlineSnapshot(`"configurableAccount"`) }) test('behavior: account simulation', () => { @@ -288,8 +288,7 @@ describe('toRpc', () => { "value": "0x", }, ], - "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "multisigSimulation": { + "configurableAccountSimulation": { "approvals": [ { "keyData": "0x0005", @@ -299,6 +298,7 @@ describe('toRpc', () => { ], "config": "0xf852a011111111111111111111111111111111111111111111111111111111111111118002eed694111111111111111111111111111111111111111101d694bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb01", }, + "from": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "type": "0x76", } `) @@ -445,7 +445,7 @@ describe('roundtrip', () => { nonceKey: '0xff', gas: '0x186a0', keyAuthorizationSimulation: accountSimulationRpc, - multisigSimulation: accountSimulationRpc, + configurableAccountSimulation: accountSimulationRpc, type: '0x76', } @@ -461,7 +461,9 @@ describe('roundtrip', () => { expect(rpc.keyAuthorizationSimulation).toEqual( original.keyAuthorizationSimulation, ) - expect(rpc.multisigSimulation).toEqual(original.multisigSimulation) + expect(rpc.configurableAccountSimulation).toEqual( + original.configurableAccountSimulation, + ) expect(rpc.type).toBe('0x76') }) }) diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index 450936d69..c01d7df09 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -70,9 +70,9 @@ export type Rpc = Omit< feeToken?: Hex.Hex | undefined keyAuthorization?: KeyAuthorization.Rpc | undefined keyAuthorizationSimulation?: AccountSimulation.Rpc | undefined - /** Key type. Configurable accounts use the node's `multisig` key type. */ - keyType?: SignatureEnvelope.Type | 'multisig' | undefined - multisigSimulation?: AccountSimulation.Rpc | undefined + /** Key type. Configurable accounts use the node's `configurableAccount` key type. */ + keyType?: SignatureEnvelope.Type | 'configurableAccount' | undefined + configurableAccountSimulation?: AccountSimulation.Rpc | undefined nonceKey?: Hex.Hex | undefined signature?: SignatureEnvelope.SignatureEnvelopeRpc | undefined } @@ -104,7 +104,7 @@ export function fromRpc(request: Rpc): TransactionRequest { authorizationList: _, keyAuthorizationSimulation: __, keyType: ___, - multisigSimulation: ____, + configurableAccountSimulation: ____, ...rest } = request const request_ = ox_TransactionRequest.fromRpc( @@ -147,10 +147,12 @@ export function fromRpc(request: Rpc): TransactionRequest { ) if (typeof request.keyType !== 'undefined') request_.keyType = - request.keyType === 'multisig' ? 'configurable' : request.keyType - if (request.multisigSimulation) + request.keyType === 'configurableAccount' + ? 'configurable' + : request.keyType + if (request.configurableAccountSimulation) request_.accountSimulation = AccountSimulation.fromRpc( - request.multisigSimulation, + request.configurableAccountSimulation, ) if (typeof request.validBefore !== 'undefined') request_.validBefore = Hex.toNumber(request.validBefore as Hex.Hex) @@ -292,13 +294,15 @@ export function toRpc(request: toRpc.Input): Rpc { if (typeof request.keyId !== 'undefined') request_rpc.keyId = request.keyId if (typeof request.keyType !== 'undefined') request_rpc.keyType = - request.keyType === 'configurable' ? 'multisig' : request.keyType + request.keyType === 'configurable' + ? 'configurableAccount' + : request.keyType if (typeof request.keyAuthorizationSimulation !== 'undefined') request_rpc.keyAuthorizationSimulation = AccountSimulation.toRpc( request.keyAuthorizationSimulation, ) if (typeof request.accountSimulation !== 'undefined') - request_rpc.multisigSimulation = AccountSimulation.toRpc( + request_rpc.configurableAccountSimulation = AccountSimulation.toRpc( request.accountSimulation, ) if (typeof request.validBefore !== 'undefined') diff --git a/src/zod/tempo/AccountSimulation.ts b/src/zod/tempo/AccountSimulation.ts index 7c6671b8a..5463b841e 100644 --- a/src/zod/tempo/AccountSimulation.ts +++ b/src/zod/tempo/AccountSimulation.ts @@ -21,6 +21,7 @@ export const Domain = z.object({ .check(z.maxLength(core_AccountConfig.maxSignatures)), ), config: z_AccountConfig.Config, + signer: z.optional(z_Address.Address), }) const Rpc_ = z @@ -31,6 +32,7 @@ const Rpc_ = z .check(z.maxLength(core_AccountConfig.maxSignatures)), ), config: z_Hex.Hex, + signer: z.optional(z_Address.Address), }) .check( z.refine((value) => { diff --git a/src/zod/tempo/KeyAuthorization.ts b/src/zod/tempo/KeyAuthorization.ts index b3a16a48b..abb3e1ae7 100644 --- a/src/zod/tempo/KeyAuthorization.ts +++ b/src/zod/tempo/KeyAuthorization.ts @@ -29,28 +29,24 @@ export const RpcCallScope = z.object({ }) /** RPC key authorization schema. */ -export const Rpc = z - .object({ - account: z.optional(z.nullable(z_Address.Address)), - allowedCalls: z.optional(z.nullable(z.readonly(z.array(RpcCallScope)))), - chainId: z_Hex.Hex, - expiry: z.optional(z.nullable(z_Hex.Hex)), - isAdmin: z.optional(z.nullable(z.boolean())), - keyId: z_Address.Address, - keyType: z.union([z_SignatureEnvelope.Type, z.literal('multisig')]), - limits: z.optional(z.nullable(z.readonly(z.array(RpcTokenLimit)))), - signature: z.union([ - z_SignatureEnvelope.PrimitiveRpc, - z_SignatureEnvelope.ConfigurableRpc, - ]), - witness: z.optional(z.nullable(z_Hex.Hex)), - }) - .check( - z.refine( - (value) => value.keyType !== 'multisig' || value.account != null, - 'configurable account key grants require a parent account binding', - ), - ) +export const Rpc = z.object({ + account: z.optional(z.nullable(z_Address.Address)), + allowedCalls: z.optional(z.nullable(z.readonly(z.array(RpcCallScope)))), + chainId: z_Hex.Hex, + expiry: z.optional(z.nullable(z_Hex.Hex)), + isAdmin: z.optional(z.nullable(z.boolean())), + keyId: z_Address.Address, + keyType: z.union([ + z_SignatureEnvelope.Type, + z.literal('configurableAccount'), + ]), + limits: z.optional(z.nullable(z.readonly(z.array(RpcTokenLimit)))), + signature: z.union([ + z_SignatureEnvelope.PrimitiveRpc, + z_SignatureEnvelope.ConfigurableRpc, + ]), + witness: z.optional(z.nullable(z_Hex.Hex)), +}) /** Token spending limit schema. */ export const TokenLimit = z.object({ @@ -88,18 +84,11 @@ const domainShape = { } /** Decoded key authorization schema. */ -export const Domain = z - .object({ - ...domainShape, - account: z.optional(z_Address.Address), - isAdmin: z.optional(z.boolean()), - }) - .check( - z.refine( - (value) => value.type !== 'configurable' || value.account != null, - 'configurable account key grants require a parent account binding', - ), - ) +export const Domain = z.object({ + ...domainShape, + account: z.optional(z_Address.Address), + isAdmin: z.optional(z.boolean()), +}) const domainToRpcShape = { ...domainShape, @@ -109,18 +98,11 @@ const domainToRpcShape = { } /** Encode-only decoded key authorization schema accepting numberish `toRpc` inputs. */ -export const DomainToRpc = z - .object({ - ...domainToRpcShape, - account: z.optional(z_Address.Address), - isAdmin: z.optional(z.boolean()), - }) - .check( - z.refine( - (value) => value.type !== 'configurable' || value.account != null, - 'configurable account key grants require a parent account binding', - ), - ) +export const DomainToRpc = z.object({ + ...domainToRpcShape, + account: z.optional(z_Address.Address), + isAdmin: z.optional(z.boolean()), +}) /** Codec decoding an RPC key authorization into a signed key authorization. */ export const KeyAuthorization = z.codec(Rpc, Domain, { diff --git a/src/zod/tempo/TransactionRequest.ts b/src/zod/tempo/TransactionRequest.ts index 4f9e3ea53..5b66892d9 100644 --- a/src/zod/tempo/TransactionRequest.ts +++ b/src/zod/tempo/TransactionRequest.ts @@ -26,12 +26,12 @@ const KeyType = z.union([ z.literal('configurable'), ]) -// The node's wire format names the configurable account key type `multisig`. +// The node's wire format names the configurable account key type `configurableAccount`. const KeyTypeRpc = z.union([ z.literal('secp256k1'), z.literal('p256'), z.literal('webAuthn'), - z.literal('multisig'), + z.literal('configurableAccount'), ]) const SignatureRpc = z.object({ @@ -93,7 +93,7 @@ export const Rpc = z.object({ maxFeePerGas: z.optional(z_Hex.Hex), maxPriorityFeePerGas: z.optional(z_Hex.Hex), keyAuthorizationSimulation: z.optional(z_AccountSimulation.Rpc), - multisigSimulation: z.optional(z_AccountSimulation.Rpc), + configurableAccountSimulation: z.optional(z_AccountSimulation.Rpc), nonce: z.optional(z_Hex.Hex), nonceKey: z.optional(z_Hex.Hex), r: z.optional(z_Hex.Hex), @@ -218,7 +218,11 @@ export const TransactionRequestToRpc = z.codec(Rpc, DomainToRpc, { function fromRpc( request: core_TransactionRequest.Rpc, ): core_TransactionRequest.TransactionRequest { - const { authorizationList: _, multisigSimulation: __, ...rest } = request + const { + authorizationList: _, + configurableAccountSimulation: __, + ...rest + } = request const request_ = z.decode( z_TransactionRequest.TransactionRequest, rest as never, @@ -263,16 +267,18 @@ function fromRpc( if (typeof request.keyId !== 'undefined') request_.keyId = request.keyId if (typeof request.keyType !== 'undefined') request_.keyType = - request.keyType === 'multisig' ? 'configurable' : request.keyType + request.keyType === 'configurableAccount' + ? 'configurable' + : request.keyType if (typeof request.keyAuthorizationSimulation !== 'undefined') request_.keyAuthorizationSimulation = z.decode( z_AccountSimulation.AccountSimulation, request.keyAuthorizationSimulation, ) - if (typeof request.multisigSimulation !== 'undefined') + if (typeof request.configurableAccountSimulation !== 'undefined') request_.accountSimulation = z.decode( z_AccountSimulation.AccountSimulation, - request.multisigSimulation, + request.configurableAccountSimulation, ) if (typeof request.validBefore !== 'undefined') request_.validBefore = core_Hex.toNumber(request.validBefore) @@ -357,14 +363,16 @@ function toRpc( if (typeof request.keyId !== 'undefined') request_rpc.keyId = request.keyId if (typeof request.keyType !== 'undefined') request_rpc.keyType = - request.keyType === 'configurable' ? 'multisig' : request.keyType + request.keyType === 'configurable' + ? 'configurableAccount' + : request.keyType if (typeof request.keyAuthorizationSimulation !== 'undefined') request_rpc.keyAuthorizationSimulation = z.encode( z_AccountSimulation.AccountSimulation, request.keyAuthorizationSimulation, ) if (typeof request.accountSimulation !== 'undefined') - request_rpc.multisigSimulation = z.encode( + request_rpc.configurableAccountSimulation = z.encode( z_AccountSimulation.AccountSimulation, request.accountSimulation, ) diff --git a/src/zod/tempo/_test/AccountOperation.test.ts b/src/zod/tempo/_test/AccountOperation.test.ts index a36547e2f..343888665 100644 --- a/src/zod/tempo/_test/AccountOperation.test.ts +++ b/src/zod/tempo/_test/AccountOperation.test.ts @@ -24,7 +24,7 @@ const rpc = { version: '0x1', }, createdAt: 1, - hash: '0xcdbc24a8fb192f799c5d166b13a99fb29cbb15e71a5e730988d6f8b3c5959d02', + hash: '0x2b7a9f98ab46088ccfbd145553b5ec804cd6a24eab46a21f023cbe68385f0fb4', signatureCount: 1, status: 'pending', threshold: 2, diff --git a/src/zod/tempo/_test/KeyAuthorization.test.ts b/src/zod/tempo/_test/KeyAuthorization.test.ts index 96b891641..91105528d 100644 --- a/src/zod/tempo/_test/KeyAuthorization.test.ts +++ b/src/zod/tempo/_test/KeyAuthorization.test.ts @@ -21,7 +21,7 @@ const rpc = { } as const describe('KeyAuthorization', () => { - test('rejects unbound configurable grants through every schema', () => { + test('accepts unbound configurable grants through every schema', () => { const domain = { ...z.decode(z_KeyAuthorization.KeyAuthorization, rpc), type: 'configurable', @@ -30,15 +30,15 @@ describe('KeyAuthorization', () => { expect( z.safeDecode(z_KeyAuthorization.KeyAuthorization, { ...rpc, - keyType: 'multisig', + keyType: 'configurableAccount', account, }).success, - ).toMatchInlineSnapshot(`false`) + ).toMatchInlineSnapshot(`true`) for (const schema of [ z_KeyAuthorization.Domain, z_KeyAuthorization.DomainToRpc, ]) { - expect(z.safeParse(schema, domain).success).toMatchInlineSnapshot(`false`) + expect(z.safeParse(schema, domain).success).toMatchInlineSnapshot(`true`) expect( z.safeParse(schema, { ...domain, account: rpc.keyId }).success, ).toMatchInlineSnapshot(`true`) @@ -47,7 +47,7 @@ describe('KeyAuthorization', () => { z_KeyAuthorization.KeyAuthorization, z_KeyAuthorization.KeyAuthorizationToRpc, ]) - expect(z.safeEncode(codec, domain).success).toMatchInlineSnapshot(`false`) + expect(z.safeEncode(codec, domain).success).toMatchInlineSnapshot(`true`) }) test('decodes an RPC key authorization', () => { diff --git a/src/zod/tempo/_test/TransactionRequest.test.ts b/src/zod/tempo/_test/TransactionRequest.test.ts index e7282f7a1..5e69839b9 100644 --- a/src/zod/tempo/_test/TransactionRequest.test.ts +++ b/src/zod/tempo/_test/TransactionRequest.test.ts @@ -79,13 +79,13 @@ describe('TransactionRequest', () => { const decoded = z.decode(z_TransactionRequest.TransactionRequest, { ...rpc, keyAuthorizationSimulation: accountSimulationRpc, - multisigSimulation: accountSimulationRpc, + configurableAccountSimulation: accountSimulationRpc, }) expect(decoded).toEqual( core_TransactionRequest.fromRpc({ ...rpc, keyAuthorizationSimulation: accountSimulationRpc, - multisigSimulation: accountSimulationRpc, + configurableAccountSimulation: accountSimulationRpc, }), ) expect(decoded.accountSimulation).toEqual( @@ -96,22 +96,27 @@ describe('TransactionRequest', () => { expect(encoded.keyAuthorizationSimulation).toStrictEqual( accountSimulationRpc, ) - expect(encoded.multisigSimulation).toStrictEqual(accountSimulationRpc) + expect(encoded.configurableAccountSimulation).toStrictEqual( + accountSimulationRpc, + ) expect(encoded).toEqual(core_TransactionRequest.toRpc(decoded)) }) - test('configurable key type maps to the multisig RPC key type', () => { + test('configurable key type maps to the configurableAccount RPC key type', () => { const decoded = z.decode(z_TransactionRequest.TransactionRequest, { ...rpc, - keyType: 'multisig', + keyType: 'configurableAccount', }) expect(decoded.keyType).toBe('configurable') expect(decoded).toEqual( - core_TransactionRequest.fromRpc({ ...rpc, keyType: 'multisig' }), + core_TransactionRequest.fromRpc({ + ...rpc, + keyType: 'configurableAccount', + }), ) const encoded = z.encode(z_TransactionRequest.TransactionRequest, decoded) - expect(encoded.keyType).toBe('multisig') + expect(encoded.keyType).toBe('configurableAccount') expect(encoded).toEqual(core_TransactionRequest.toRpc(decoded)) }) diff --git a/test/tempo/configurableAccounts.genesis.json b/test/tempo/configurableAccounts.genesis.json index 8c20a92dc..c534d2266 100644 --- a/test/tempo/configurableAccounts.genesis.json +++ b/test/tempo/configurableAccounts.genesis.json @@ -25,6 +25,7 @@ "t11Time": 0, "t12Time": 0, "t13Time": 0, + "t14Time": 0, "t1Time": 0, "t1aTime": 0, "t1bTime": 0, diff --git a/test/tempo/configurableAccounts.ts b/test/tempo/configurableAccounts.ts index d46e6ac81..13928c0ca 100644 --- a/test/tempo/configurableAccounts.ts +++ b/test/tempo/configurableAccounts.ts @@ -1,6 +1,8 @@ export const port = 3001 -export const tag = 'sha-83f3ccd' +// Image built from tempoxyz/tempo `joshie/configurable-t14-devnet-checkpoint` with a +// configurable account recovery factory genesis option (`liam/t14-checkpoint-recovery-factory`). +export const tag = 'sha-04e67d3' // Development-only factory configured in the configurable accounts test genesis. export const factory = '0x7171717171717171717171717171717171717171' diff --git a/test/tempo/setup.global.configurableAccounts.ts b/test/tempo/setup.global.configurableAccounts.ts index 631314c0a..113b072f3 100644 --- a/test/tempo/setup.global.configurableAccounts.ts +++ b/test/tempo/setup.global.configurableAccounts.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs' import { factory, port, tag } from './configurableAccounts.js' import { createServer } from './prool.js' -// Genesis derives from Tempo sha-83f3ccd dev.json, retaining funded tokens and native precompiles. +// Genesis derives from Tempo sha-83f3ccd dev.json, retaining funded tokens and native precompiles, +// with T14 activated for configurable accounts. // The test-only recovery factory enables configurable-account registration. export default async function () { const genesis = JSON.parse( @@ -16,7 +17,10 @@ export default async function () { tag, chain: JSON.stringify({ ...genesis, - config: { ...genesis.config, multisigRecoveryFactory: factory }, + config: { + ...genesis.config, + configurableAccountRecoveryFactory: factory, + }, }), }) return server.start()