diff --git a/.changeset/bright-buckets-tap.md b/.changeset/bright-buckets-tap.md new file mode 100644 index 00000000..d78b53f4 --- /dev/null +++ b/.changeset/bright-buckets-tap.md @@ -0,0 +1,5 @@ +--- +"accounts": patch +--- + +Added support for admin access keys + witness. diff --git a/package.json b/package.json index 025b5499..305e974a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "jose": "^6.2.3", "mipd": "^0.0.7", "mppx": "catalog:", - "ox": "~0.14.20", + "ox": "~0.14.29", "wata": "0.4.0", "webauthx": "~0.1.1", "zod": "^4.3.6", diff --git a/src/cli/adapter.test.ts b/src/cli/adapter.test.ts new file mode 100644 index 00000000..aa1c6934 --- /dev/null +++ b/src/cli/adapter.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vp/test' +import * as z from 'zod/mini' + +import { chain, getClient } from '../../test/config.js' +import * as Account from '../core/Account.js' +import * as Storage from '../core/Storage.js' +import * as Store from '../core/Store.js' +import * as Rpc from '../core/zod/rpc.js' +import { cli } from './adapter.js' + +describe('cli', () => { + test('error: rejects unsupported T5 fields for wallet_authorizeAccessKey', async () => { + const { adapter } = setup() + const parameters: Rpc.wallet_authorizeAccessKey.Decoded['params'][number] = { + account: '0x0000000000000000000000000000000000000001', + expiry: 0, + } + + await expect( + adapter.actions.authorizeAccessKey!(parameters, { + method: 'wallet_authorizeAccessKey', + params: z.encode(Rpc.wallet_authorizeAccessKey.schema.params!, [parameters]), + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RpcResponse.InvalidParamsError: \`authorizeAccessKey.account\`, \`authorizeAccessKey.isAdmin\`, and \`authorizeAccessKey.witness\` are not supported by the CLI adapter.]`, + ) + }) + + test('error: rejects unsupported T5 fields for wallet_connect login', async () => { + const { adapter } = setup() + + await expect( + adapter.actions.loadAccounts( + { + authorizeAccessKey: { + expiry: 0, + isAdmin: false, + }, + }, + { method: 'wallet_connect', params: undefined }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RpcResponse.InvalidParamsError: \`authorizeAccessKey.account\`, \`authorizeAccessKey.isAdmin\`, and \`authorizeAccessKey.witness\` are not supported by the CLI adapter.]`, + ) + }) + + test('error: rejects unsupported T5 fields for wallet_connect register', async () => { + const { adapter } = setup() + + await expect( + adapter.actions.createAccount( + { + authorizeAccessKey: { + expiry: 0, + witness: `0x${'11'.repeat(32)}`, + }, + name: 'test', + }, + { method: 'wallet_connect', params: undefined }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RpcResponse.InvalidParamsError: \`authorizeAccessKey.account\`, \`authorizeAccessKey.isAdmin\`, and \`authorizeAccessKey.witness\` are not supported by the CLI adapter.]`, + ) + }) +}) + +function setup() { + const storage = Storage.memory() + const store = Store.create({ chainId: chain.id, storage }) + const adapter = cli({ + host: 'http://localhost/cli-auth', + open() { + throw new Error('Unexpected browser open.') + }, + })({ + getAccount: (options) => Account.find({ ...options, signable: true, store }), + getClient: () => getClient({ chain }) as never, + storage, + store, + }) + return { adapter, store } +} diff --git a/src/cli/adapter.ts b/src/cli/adapter.ts index d4404213..54fe959c 100644 --- a/src/cli/adapter.ts +++ b/src/cli/adapter.ts @@ -53,6 +53,8 @@ export function cli(options: cli.Options): Adapter.Adapter { } = options const { account, authorizeAccessKey, method } = request + rejectUnsupportedAuthorizeAccessKey(authorizeAccessKey) + // p256 by default; secp256k1 only when explicitly requested. const generatedKeyType = authorizeAccessKey?.keyType === 'secp256k1' ? 'secp256k1' : 'p256' const generatedAccessKey = @@ -337,3 +339,17 @@ async function post< function unsupported(message: string) { return new core_Provider.UnsupportedMethodError({ message }) } + +function rejectUnsupportedAuthorizeAccessKey( + authorizeAccessKey: Adapter.authorizeAccessKey.Parameters | undefined, +) { + if ( + authorizeAccessKey?.account || + typeof authorizeAccessKey?.isAdmin !== 'undefined' || + authorizeAccessKey?.witness + ) + throw new RpcResponse.InvalidParamsError({ + message: + '`authorizeAccessKey.account`, `authorizeAccessKey.isAdmin`, and `authorizeAccessKey.witness` are not supported by the CLI adapter.', + }) +} diff --git a/src/core/AccessKey.test.ts b/src/core/AccessKey.test.ts index 469bb7b3..b4d47084 100644 --- a/src/core/AccessKey.test.ts +++ b/src/core/AccessKey.test.ts @@ -21,21 +21,26 @@ const rootAddress = accounts[0]!.address function createKeyAuthorization( address: `0x${string}`, options: { + account?: `0x${string}` | undefined chainId?: bigint | undefined expiry?: number | undefined + isAdmin?: boolean | undefined keyType?: KeyAuthorization.KeyAuthorization['type'] | undefined limits?: { token: `0x${string}`; limit: bigint; period?: number | undefined }[] | undefined scopes?: KeyAuthorization.Scope[] | undefined + witness?: Hex.Hex | undefined } = {}, ) { return KeyAuthorization.from( { + ...(options.account ? { account: options.account, isAdmin: options.isAdmin ?? false } : {}), address, chainId: options.chainId ?? 1n, expiry: options.expiry, limits: options.limits, scopes: options.scopes, type: options.keyType ?? 'p256', + ...(options.witness ? { witness: options.witness } : {}), }, { signature: SignatureEnvelope.from(`0x${'00'.repeat(65)}`) }, ) @@ -208,6 +213,54 @@ describe('add', () => { }) }) +describe('updateAuthorization', () => { + test('behavior: syncs key authorization matching metadata', () => { + const store = createStore() + const accessKey = accounts[1]!.address + const witness = `0x${'11'.repeat(32)}` as const + const initialAuthorization = createKeyAuthorization(accessKey) + const constrainedAuthorization = createKeyAuthorization(accessKey, { + account: rootAddress, + isAdmin: true, + witness, + }) + const unconstrainedAuthorization = createKeyAuthorization(accessKey) + + addAuthorization({ + address: rootAddress, + keyAuthorization: initialAuthorization, + store, + }) + + store.accessKeys.updateAuthorization({ + account: rootAddress, + accessKey, + authorization: constrainedAuthorization, + chainId: 1, + }) + + expect(store.getState().accessKeys[0]).toMatchObject({ + account: rootAddress, + isAdmin: true, + keyAuthorization: constrainedAuthorization, + witness, + }) + + store.accessKeys.updateAuthorization({ + account: rootAddress, + accessKey, + authorization: unconstrainedAuthorization, + chainId: 1, + }) + + const updatedAccessKey = store.getState().accessKeys[0]! + expect(updatedAccessKey.keyAuthorization).toBe(unconstrainedAuthorization) + expect(updatedAccessKey).not.toHaveProperty('account') + expect(updatedAccessKey).not.toHaveProperty('isAdmin') + expect(updatedAccessKey).not.toHaveProperty('witness') + }) +}) + describe('create invalidation', () => { async function setup(options: { other?: boolean | undefined } = {}) { const store = createStore() @@ -358,10 +411,13 @@ describe('prepareAuthorization', () => { }) test('behavior: prepares external key authorization from address', async () => { + const witness = `0x${'11'.repeat(32)}` as const const result = await AccessKey.prepareAuthorization({ + account: rootAddress, address: accounts[1]!.address, chainId: 123n, expiry: 456, + isAdmin: false, keyType: 'webAuthn', limits: [ { @@ -377,14 +433,17 @@ describe('prepareAuthorization', () => { selector: 'transfer(address,uint256)', }, ], + witness, }) expect(result.key).toBeUndefined() expect(result.keyAuthorization).toMatchInlineSnapshot(` { + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "address": "${accounts[1]!.address}", "chainId": 123n, "expiry": 456, + "isAdmin": false, "limits": [ { "limit": 1000n, @@ -402,6 +461,7 @@ describe('prepareAuthorization', () => { }, ], "type": "webAuthn", + "witness": "0x1111111111111111111111111111111111111111111111111111111111111111", } `) }) @@ -460,6 +520,30 @@ describe('prepareAuthorization', () => { expect(result.keyAuthorization.type).toMatchInlineSnapshot(`"secp256k1"`) }) + + test('behavior: explicit false admin flag is a no-op without account', async () => { + const result = await AccessKey.prepareAuthorization({ + address: accounts[1]!.address, + chainId: 1, + expiry: 123, + isAdmin: false, + }) + + expect('isAdmin' in result.keyAuthorization).toMatchInlineSnapshot(`false`) + }) + + test('error: rejects admin authorization without account', async () => { + await expect( + AccessKey.prepareAuthorization({ + address: accounts[1]!.address, + chainId: 1, + expiry: 123, + isAdmin: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RpcResponse.InvalidParamsError: \`isAdmin\` requires \`account\`.]`, + ) + }) }) describe('authorize', () => { @@ -604,6 +688,102 @@ describe('authorize', () => { ] `) }) + + test('behavior: infers admin account from signer', async () => { + const store = createStore() + const account = { + ...accounts[0]!, + sign: async () => `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const, + } as TempoAccount.Account + + const result = await store.accessKeys.authorize({ + account, + chainId: 1, + parameters: { + address: accounts[1]!.address, + expiry: 123, + isAdmin: true, + }, + }) + + expect({ + result: { + account: result.account, + isAdmin: result.isAdmin, + }, + stored: store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey), + }).toMatchInlineSnapshot(` + { + "result": { + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "isAdmin": true, + }, + "stored": [ + { + "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", + "chainId": 1, + "expiry": 123, + "isAdmin": true, + "keyType": "secp256k1", + "limits": undefined, + "scopes": undefined, + }, + ], + } + `) + }) + + test('behavior: infers witness account from signer', async () => { + const store = createStore() + const account = { + ...accounts[0]!, + sign: async () => `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const, + } as TempoAccount.Account + const witness = `0x${'33'.repeat(32)}` as const + + const result = await store.accessKeys.authorize({ + account, + chainId: 1, + parameters: { + address: accounts[1]!.address, + expiry: 123, + witness, + }, + }) + + expect({ + result: { + account: result.account, + isAdmin: result.isAdmin, + witness: result.witness, + }, + stored: store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey), + }).toMatchInlineSnapshot(` + { + "result": { + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "isAdmin": undefined, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + "stored": [ + { + "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", + "chainId": 1, + "expiry": 123, + "isAdmin": false, + "keyType": "secp256k1", + "limits": undefined, + "scopes": undefined, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + ], + } + `) + }) }) describe('select', () => { @@ -923,6 +1103,82 @@ describe('hasReusableAuthorization', () => { } `) }) + + test('behavior: requires exact account and admin parameters', async () => { + const store = createStore() + const keyPair = await WebCryptoP256.createKeyPair() + const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) + addAuthorization({ + address: rootAddress, + keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, { + account: rootAddress, + isAdmin: true, + }), + keyPair, + store, + }) + + const match = await AccessKey.hasReusableAuthorization({ + account: rootAddress, + chainId: 1, + parameters: { account: rootAddress, expiry: 300, isAdmin: true }, + store: { keystores: Keystore.defaults, state: store }, + }) + const miss_admin = await AccessKey.hasReusableAuthorization({ + account: rootAddress, + chainId: 1, + parameters: { account: rootAddress, expiry: 300, isAdmin: false }, + store: { keystores: Keystore.defaults, state: store }, + }) + const miss_account = await AccessKey.hasReusableAuthorization({ + account: rootAddress, + chainId: 1, + parameters: { expiry: 300 }, + store: { keystores: Keystore.defaults, state: store }, + }) + + expect({ match, miss_account, miss_admin }).toMatchInlineSnapshot(` + { + "match": true, + "miss_account": false, + "miss_admin": false, + } + `) + }) + + test('behavior: never reuses witness-bound authorizations', async () => { + const store = createStore() + const keyPair = await WebCryptoP256.createKeyPair() + const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) + addAuthorization({ + address: rootAddress, + keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, { + witness: `0x${'11'.repeat(32)}`, + }), + keyPair, + store, + }) + + const stored = await AccessKey.hasReusableAuthorization({ + account: rootAddress, + chainId: 1, + parameters: { expiry: 300 }, + store: { keystores: Keystore.defaults, state: store }, + }) + const requested = await AccessKey.hasReusableAuthorization({ + account: rootAddress, + chainId: 1, + parameters: { expiry: 300, witness: `0x${'22'.repeat(32)}` }, + store: { keystores: Keystore.defaults, state: store }, + }) + + expect({ requested, stored }).toMatchInlineSnapshot(` + { + "requested": false, + "stored": false, + } + `) + }) }) describe('canAuthorizeCalls', () => { diff --git a/src/core/AccessKey.ts b/src/core/AccessKey.ts index d0c4458d..e502bb6f 100644 --- a/src/core/AccessKey.ts +++ b/src/core/AccessKey.ts @@ -30,6 +30,8 @@ type Status = (typeof status)[keyof typeof status] /** Access key entry stored alongside accounts. */ export type AccessKey = { + /** Account this authorization targets. */ + account?: Address.Address | undefined /** Access key address. */ address: Address.Address /** Owner of the access key. */ @@ -42,6 +44,8 @@ export type AccessKey = { keyAuthorization?: KeyAuthorization.Signed | undefined /** Key type. */ keyType: 'secp256k1' | 'p256' | 'webAuthn' | 'webCrypto' + /** Whether this authorization grants admin key privileges. */ + isAdmin?: boolean | undefined /** TIP-20 spending limits for the access key. */ limits?: { token: Address.Address; limit: bigint; period?: number | undefined }[] | undefined /** Call scopes restricting which contracts/selectors this key can call. */ @@ -52,6 +56,8 @@ export type AccessKey = { recipients?: readonly Address.Address[] | undefined }[] | undefined + /** TIP-1053 witness bound into the key authorization. */ + witness?: Hex.Hex | undefined } & OneOf< | {} | { @@ -231,9 +237,11 @@ export async function prepareAuthorization( options: prepareAuthorization.Options, ): Promise { const { + account, address, chainId, expiry, + isAdmin, keystores, keyType, limits, @@ -242,6 +250,7 @@ export async function prepareAuthorization( scopes, witness, } = options + const accountOptions = getAccountOptions({ account, isAdmin }) if (privateKey) { const type = keyType ?? 'secp256k1' @@ -261,6 +270,7 @@ export async function prepareAuthorization( address: accessKey.address, chainId: BigInt(chainId), expiry, + ...accountOptions, limits, scopes, type, @@ -274,6 +284,7 @@ export async function prepareAuthorization( address: address ?? Address.fromPublicKey(PublicKey.from(publicKey!)), chainId: BigInt(chainId), expiry, + ...accountOptions, limits, scopes, type: keyType ?? 'secp256k1', @@ -295,6 +306,7 @@ export async function prepareAuthorization( address: Address.fromPublicKey(PublicKey.fromHex(key.publicKey)), chainId: BigInt(chainId), expiry, + ...accountOptions, limits, scopes, type, @@ -303,15 +315,29 @@ export async function prepareAuthorization( return { key: { handle: key.handle, publicKey: key.publicKey }, keyAuthorization } } +function getAccountOptions(options: { + account?: Address.Address | undefined + isAdmin?: boolean | undefined +}) { + const { account, isAdmin } = options + if (isAdmin && !account) + throw new RpcResponse.InvalidParamsError({ message: '`isAdmin` requires `account`.' }) + return account ? ({ account, isAdmin: isAdmin ?? false } as const) : {} +} + export declare namespace prepareAuthorization { /** Options for {@link prepareAuthorization}. */ type Options = { + /** Account this authorization targets. */ + account?: Address.Address | undefined /** External access key address. Alternative to `publicKey`. */ address?: Address.Address | undefined /** Chain ID the key authorization is scoped to. */ chainId: bigint | number /** Unix timestamp when the key expires. */ expiry: number + /** Whether this authorization grants admin key privileges. */ + isAdmin?: boolean | undefined /** * Keystores used to create key material when none is provided. * @default Keystore.defaults @@ -352,6 +378,9 @@ export async function authorize(options: authorize.Options): Promise[number]['showDeposit'] type Parameters = { + /** Account this authorization targets. */ + account?: Address | undefined /** Access key address. Alternative to `publicKey` when the caller already knows the derived address. */ address?: Address | undefined /** Chain ID the key authorization is scoped to. Defaults to the active chain. */ chainId?: bigint | undefined /** Unix timestamp (seconds) when the key expires. */ expiry: number + /** Whether this authorization grants admin key privileges. */ + isAdmin?: boolean | undefined /** External key type. Defaults to `secp256k1` for external keys. */ keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined /** TIP-20 spending limits for this key. */ @@ -427,6 +431,8 @@ export declare namespace authorizeAccessKey { | undefined /** Show the deposit flow after the access-key authorization succeeds. */ showDeposit?: ShowDeposit | undefined + /** TIP-1053 witness to bind into the key authorization. */ + witness?: Hex | undefined } type ReturnType = { diff --git a/src/core/Provider.localnet.test.ts b/src/core/Provider.localnet.test.ts index 68a99131..ff3bcd54 100644 --- a/src/core/Provider.localnet.test.ts +++ b/src/core/Provider.localnet.test.ts @@ -1836,6 +1836,167 @@ describe.each(adapters)('$name', ({ adapter, name }: (typeof adapters)[number]) expect(result.rootAddress).toBe(rootAddress) }) + test('behavior: grants an admin access key', async () => { + const provider = Provider.create({ adapter: adapter(), chains: [chain] }) + const rootAddress = await connect(provider) + await fund(rootAddress) + + const result = await provider.request({ + method: 'wallet_authorizeAccessKey', + params: [ + { + expiry: Expiry.days(1), + isAdmin: true, + keyType: 'secp256k1', + }, + ], + }) + const accessKey = result.keyAuthorization.keyId + + const receipt = await provider.request({ + method: 'eth_sendTransactionSync', + params: [{ calls: [transferCall] }], + }) + const isAdmin = await Actions.accessKey.isAdmin(provider.getClient(), { + account: rootAddress, + accessKey, + }) + const authorization = KeyAuthorization.fromRpc(result.keyAuthorization) + const record = provider.store.getState().accessKeys[0]! + + expect({ + authorization: { + account: authorization.account, + address: authorization.address, + isAdmin: authorization.isAdmin, + }, + chain: { isAdmin }, + receipt: { status: receipt.status }, + result: { + account: result.keyAuthorization.account, + address: result.keyAuthorization.address, + isAdmin: result.keyAuthorization.isAdmin, + rootAddress: result.rootAddress, + }, + stored: { + account: record.account, + access: record.access, + address: record.address, + isAdmin: record.isAdmin, + }, + }).toMatchInlineSnapshot(` + { + "authorization": { + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": true, + }, + "chain": { + "isAdmin": true, + }, + "receipt": { + "status": "0x1", + }, + "result": { + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": true, + "rootAddress": "${rootAddress}", + }, + "stored": { + "access": "${rootAddress}", + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": true, + }, + } + `) + }) + + test('behavior: grants a witness-bound access key', async () => { + const provider = Provider.create({ adapter: adapter(), chains: [chain] }) + const rootAddress = await connect(provider) + await fund(rootAddress) + const witness = `0x${'33'.repeat(32)}` as const + + const result = await provider.request({ + method: 'wallet_authorizeAccessKey', + params: [ + { + expiry: Expiry.days(1), + keyType: 'secp256k1', + witness, + }, + ], + }) + const accessKey = result.keyAuthorization.keyId + + const receipt = await provider.request({ + method: 'eth_sendTransactionSync', + params: [{ calls: [transferCall] }], + }) + const isWitnessBurned = await Actions.accessKey.isWitnessBurned(provider.getClient(), { + account: rootAddress, + witness, + }) + const authorization = KeyAuthorization.fromRpc(result.keyAuthorization) + const record = provider.store.getState().accessKeys[0]! + + expect({ + authorization: { + account: authorization.account, + address: authorization.address, + isAdmin: authorization.isAdmin, + witness: authorization.witness, + }, + chain: { isWitnessBurned }, + receipt: { status: receipt.status }, + result: { + account: result.keyAuthorization.account, + address: result.keyAuthorization.address, + isAdmin: result.keyAuthorization.isAdmin, + rootAddress: result.rootAddress, + witness: result.keyAuthorization.witness, + }, + stored: { + account: record.account, + access: record.access, + address: record.address, + isAdmin: record.isAdmin, + witness: record.witness, + }, + }).toMatchInlineSnapshot(` + { + "authorization": { + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": false, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + "chain": { + "isWitnessBurned": false, + }, + "receipt": { + "status": "0x1", + }, + "result": { + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": undefined, + "rootAddress": "${rootAddress}", + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + "stored": { + "access": "${rootAddress}", + "account": "${rootAddress}", + "address": "${accessKey}", + "isAdmin": false, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + } + `) + }) + test('behavior: granted access key is used for sendTransactionSync', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const address = await connect(provider) diff --git a/src/core/adapters/local.test.ts b/src/core/adapters/local.test.ts index 8402eee4..23560c97 100644 --- a/src/core/adapters/local.test.ts +++ b/src/core/adapters/local.test.ts @@ -90,6 +90,110 @@ describe('local', () => { } `) }) + + test('behavior: infers admin account after account selection', async () => { + const captured: { digest: Hex | undefined }[] = [] + const { adapter, store } = setup({ + loadAccounts: makeLoadAccounts(0, captured), + }) + + const result = await adapter.actions.loadAccounts( + { + authorizeAccessKey: { + address: core_accounts[1]!.address, + expiry: 0, + isAdmin: true, + }, + }, + { method: 'wallet_connect', params: undefined }, + ) + + expect({ + digest: captured[0]?.digest, + result: { + account: result.keyAuthorization?.account, + isAdmin: result.keyAuthorization?.isAdmin, + }, + stored: store + .getState() + .accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey), + }).toMatchInlineSnapshot(` + { + "digest": undefined, + "result": { + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "isAdmin": true, + }, + "stored": [ + { + "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", + "chainId": 1337, + "expiry": 0, + "isAdmin": true, + "keyType": "secp256k1", + "limits": undefined, + "scopes": undefined, + }, + ], + } + `) + }) + + test('behavior: infers witness account after account selection', async () => { + const captured: { digest: Hex | undefined }[] = [] + const { adapter, store } = setup({ + loadAccounts: makeLoadAccounts(0, captured), + }) + const witness = `0x${'33'.repeat(32)}` as const + + const result = await adapter.actions.loadAccounts( + { + authorizeAccessKey: { + address: core_accounts[1]!.address, + expiry: 0, + witness, + }, + }, + { method: 'wallet_connect', params: undefined }, + ) + + expect({ + digest: captured[0]?.digest, + result: { + account: result.keyAuthorization?.account, + isAdmin: result.keyAuthorization?.isAdmin, + witness: result.keyAuthorization?.witness, + }, + stored: store + .getState() + .accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey), + }).toMatchInlineSnapshot(` + { + "digest": undefined, + "result": { + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "isAdmin": undefined, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + "stored": [ + { + "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "account": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", + "chainId": 1337, + "expiry": 0, + "isAdmin": false, + "keyType": "secp256k1", + "limits": undefined, + "scopes": undefined, + "witness": "0x3333333333333333333333333333333333333333333333333333333333333333", + }, + ], + } + `) + }) }) describe('loadAccounts: personalSign', () => { @@ -190,6 +294,51 @@ describe('local', () => { `[ProviderRpcError: \`digest\` and \`personalSign\` cannot both be set on \`wallet_connect\`.]`, ) }) + + test('error: rejects when personalSign and explicit witness both need the ceremony witness slot', async () => { + const { adapter } = setup({}, { chain: tempoModerato }) + + await expect( + adapter.actions.loadAccounts( + { + authorizeAccessKey: { + expiry: 0, + witness: `0x${'11'.repeat(32)}`, + }, + personalSign: { message: 'hello' }, + }, + { method: 'wallet_connect', params: undefined }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[ProviderRpcError: \`personalSign\` and \`authorizeAccessKey.witness\` cannot both be set on \`wallet_connect\`.]`, + ) + }) + + test('error: rejects lossy non-admin account-bound serialized proof', async () => { + const { adapter, store } = setup( + { + loadAccounts: makeLoadAccounts(0, []), + }, + { chain: tempoModerato }, + ) + + await expect( + adapter.actions.loadAccounts( + { + authorizeAccessKey: { + account: core_accounts[0]!.address, + expiry: 0, + isAdmin: false, + }, + personalSign: { message: 'hello' }, + }, + { method: 'wallet_connect', params: undefined }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[ProviderRpcError: \`personalSign\` with a non-admin account-bound \`authorizeAccessKey\` cannot be serialized as a key authorization proof.]`, + ) + expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`) + }) }) describe('createAccount', () => { @@ -274,6 +423,41 @@ describe('local', () => { ) }) + test('error: createAccount rejects lossy non-admin account-bound serialized proof', async () => { + const { adapter, store } = setup( + { + createAccount: async () => ({ + accounts: [ + { + address: core_accounts[1].address, + keyType: 'secp256k1' as const, + privateKey: privateKeys[1]!, + }, + ], + }), + }, + { chain: tempoModerato }, + ) + + await expect( + adapter.actions.createAccount( + { + authorizeAccessKey: { + account: core_accounts[1]!.address, + expiry: 0, + isAdmin: false, + }, + name: 'test', + personalSign: { message: 'hello' }, + }, + { method: 'wallet_connect', params: undefined }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[ProviderRpcError: \`personalSign\` with a non-admin account-bound \`authorizeAccessKey\` cannot be serialized as a key authorization proof.]`, + ) + expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`) + }) + test('error: throws when createAccount not configured', async () => { const { adapter } = setup() diff --git a/src/core/adapters/local.ts b/src/core/adapters/local.ts index 39587c38..1862d2f6 100644 --- a/src/core/adapters/local.ts +++ b/src/core/adapters/local.ts @@ -1,4 +1,4 @@ -import { Provider as ox_Provider, type WebCryptoP256 } from 'ox' +import { Provider as ox_Provider, type Hex, type WebCryptoP256 } from 'ox' import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo' import { hashMessage } from 'viem' import { Account as TempoAccount } from 'viem/tempo' @@ -109,12 +109,22 @@ export function local(options: local.Options): Adapter.Adapter { grantOptions?.chainId ? { chainId: Number(grantOptions.chainId) } : undefined, ) const chainId = grantOptions?.chainId ?? client.chain.id + if (personalSign && grantOptions?.witness) + throw new ox_Provider.ProviderRpcError( + -32602, + '`personalSign` and `authorizeAccessKey.witness` cannot both be set on `wallet_connect`.', + ) // TIP-1053 witness binding (see `loadAccounts`): fold the auth // message into the access-key authorization and sign both in the // single create-account ceremony. + const needsAccount = + (!!grantOptions?.isAdmin || !!grantOptions?.witness) && !grantOptions.account const witness = - personalSign && grantOptions ? hashMessage(personalSign.message) : undefined + personalSign && grantOptions && !needsAccount + ? hashMessage(personalSign.message) + : undefined + if (witness) validatePersonalSignProof(grantOptions) const peronsalSign_digest = personalSign && !witness ? hashMessage(personalSign.message) : undefined @@ -193,7 +203,7 @@ export function local(options: local.Options): Adapter.Adapter { personalSign: { message: personalSign.message, ...(witness && keyAuthorization_signed - ? { keyAuthorization: KeyAuthorization.serialize(keyAuthorization_signed) } + ? { keyAuthorization: serializePersonalSignProof(keyAuthorization_signed) } : {}), }, } @@ -219,14 +229,25 @@ export function local(options: local.Options): Adapter.Adapter { : undefined, ) const chainId = authorizeAccessKey?.chainId ?? client.chain.id + if (personalSign && authorizeAccessKey?.witness) + throw new ox_Provider.ProviderRpcError( + -32602, + '`personalSign` and `authorizeAccessKey.witness` cannot both be set on `wallet_connect`.', + ) // TIP-1053 witness binding: when both a `personalSign` challenge and // an `authorizeAccessKey` are requested, bind the message into the // key authorization's `witness` and sign both in ONE ceremony. The // signed key authorization doubles as the auth proof. Otherwise fall // back to the two-ceremony path below. + const needsAccount = + (!!authorizeAccessKey?.isAdmin || !!authorizeAccessKey?.witness) && + !authorizeAccessKey.account const witness = - personalSign && authorizeAccessKey ? hashMessage(personalSign.message) : undefined + personalSign && authorizeAccessKey && !needsAccount + ? hashMessage(personalSign.message) + : undefined + if (witness) validatePersonalSignProof(authorizeAccessKey) // Only claim the ceremony slot with the `personalSign` digest when // NOT binding via witness — the witness path signs the key-auth @@ -234,13 +255,14 @@ export function local(options: local.Options): Adapter.Adapter { const peronsalSign_digest = personalSign && !witness ? hashMessage(personalSign.message) : undefined - const keyAuthorization_unsigned = authorizeAccessKey - ? await store.accessKeys.prepareAuthorization({ - ...authorizeAccessKey, - chainId, - ...(witness ? { witness } : {}), - }) - : undefined + const keyAuthorization_unsigned = + authorizeAccessKey && !needsAccount + ? await store.accessKeys.prepareAuthorization({ + ...authorizeAccessKey, + chainId, + ...(witness ? { witness } : {}), + }) + : undefined const keyAuthorization_digest = keyAuthorization_unsigned ? KeyAuthorization.getSignPayload(keyAuthorization_unsigned.keyAuthorization) @@ -306,7 +328,13 @@ export function local(options: local.Options): Adapter.Adapter { const keyAuthorization = keyAuthorization_signed ? KeyAuthorization.toRpc(keyAuthorization_signed) - : undefined + : authorizeAccessKey && account && !keyAuthorization_unsigned + ? await store.accessKeys.authorize({ + account, + chainId, + parameters: authorizeAccessKey, + }) + : undefined return { accounts, @@ -321,7 +349,7 @@ export function local(options: local.Options): Adapter.Adapter { // authorization; surface it so the verifier can run the // TIP-1053 check. ...(witness && keyAuthorization_signed - ? { keyAuthorization: KeyAuthorization.serialize(keyAuthorization_signed) } + ? { keyAuthorization: serializePersonalSignProof(keyAuthorization_signed) } : {}), }, } @@ -337,6 +365,25 @@ export function local(options: local.Options): Adapter.Adapter { ) } +function serializePersonalSignProof(authorization: KeyAuthorization.Signed): Hex.Hex { + if (authorization.account && !authorization.isAdmin) + throw new ox_Provider.ProviderRpcError( + -32602, + '`personalSign` with a non-admin account-bound `authorizeAccessKey` cannot be serialized as a key authorization proof.', + ) + return KeyAuthorization.serialize(authorization) +} + +function validatePersonalSignProof( + authorization: Adapter.authorizeAccessKey.Parameters | undefined, +) { + if (authorization?.account && !authorization.isAdmin) + throw new ox_Provider.ProviderRpcError( + -32602, + '`personalSign` with a non-admin account-bound `authorizeAccessKey` cannot be serialized as a key authorization proof.', + ) +} + export declare namespace local { type Options = { /** Create a new account. Optional — omit for login-only flows. */ diff --git a/src/core/zod/rpc.test.ts b/src/core/zod/rpc.test.ts index b94cf467..25b3aaa8 100644 --- a/src/core/zod/rpc.test.ts +++ b/src/core/zod/rpc.test.ts @@ -9,6 +9,7 @@ const accessKey = '0x0000000000000000000000000000000000000002' const token = '0x20c0000000000000000000000000000000000001' const recipient = '0x0000000000000000000000000000000000000003' const contract = '0x0000000000000000000000000000000000000004' +const witness = `0x${'11'.repeat(32)}` as const describe('transactionRequest.keyAuthorization', () => { test('decodes rpc key authorizations into ox key authorizations', () => { @@ -130,6 +131,70 @@ describe('transactionRequest.keyAuthorization', () => { } `) }) + + test('preserves T5 account authorization fields', () => { + const authorization = KeyAuthorization.from( + { + account, + address: accessKey, + chainId: 1n, + expiry: 123, + isAdmin: true, + type: 'p256', + witness, + }, + { signature: SignatureEnvelope.from(`0x${'00'.repeat(65)}`) }, + ) + const rpc = KeyAuthorization.toRpc(authorization) + + expect( + z.decode(Rpc.transactionRequest, { + from: account, + keyAuthorization: { ...rpc, address: rpc.keyId }, + }).keyAuthorization, + ).toMatchObject({ + account, + isAdmin: true, + witness, + }) + + expect( + z.encode(Rpc.transactionRequest, { + from: account, + keyAuthorization: authorization, + }).keyAuthorization, + ).toMatchObject({ + account, + isAdmin: true, + witness, + }) + }) + + test('decodes account without isAdmin as explicit non-admin binding', () => { + const authorization = KeyAuthorization.from( + { + account, + address: accessKey, + chainId: 1n, + expiry: 123, + isAdmin: false, + type: 'p256', + }, + { signature: SignatureEnvelope.from(`0x${'00'.repeat(65)}`) }, + ) + const rpc = KeyAuthorization.toRpc(authorization) + const { isAdmin: _isAdmin, ...rpc_withoutAdmin } = rpc + + expect( + z.decode(Rpc.transactionRequest, { + from: account, + keyAuthorization: { ...rpc_withoutAdmin, address: rpc.keyId }, + }).keyAuthorization, + ).toMatchObject({ + account, + isAdmin: false, + }) + }) }) describe('wallet_connect.capabilities.request: auth', () => { @@ -666,6 +731,26 @@ describe('wallet_authorizeAccessKey.parameters: showDeposit', () => { }) }) +describe('wallet_authorizeAccessKey.parameters: T5 fields', () => { + test('accepts account authorization fields', () => { + expect( + z.parse(Rpc.wallet_authorizeAccessKey.parameters, { + account, + expiry: 123, + isAdmin: true, + witness, + }), + ).toMatchInlineSnapshot(` + { + "account": "0x0000000000000000000000000000000000000001", + "expiry": 123, + "isAdmin": true, + "witness": "0x1111111111111111111111111111111111111111111111111111111111111111", + } + `) + }) +}) + describe('wallet_authorizeAccessKey_strict.parameters: showDeposit', () => { test('accepts showDeposit with required access-key policy', () => { expect( @@ -695,6 +780,39 @@ describe('wallet_authorizeAccessKey_strict.parameters: showDeposit', () => { }) }) +describe('wallet_authorizeAccessKey_strict.parameters: T5 fields', () => { + test('accepts account authorization fields with required access-key policy', () => { + expect( + z.parse(Rpc.wallet_authorizeAccessKey_strict.parameters, { + account, + expiry: 123, + isAdmin: true, + limits: [{ limit: 1n, token: '0x0000000000000000000000000000000000000001' }], + scopes: [{ address: '0x0000000000000000000000000000000000000001' }], + witness, + }), + ).toMatchInlineSnapshot(` + { + "account": "0x0000000000000000000000000000000000000001", + "expiry": 123, + "isAdmin": true, + "limits": [ + { + "limit": 1n, + "token": "0x0000000000000000000000000000000000000001", + }, + ], + "scopes": [ + { + "address": "0x0000000000000000000000000000000000000001", + }, + ], + "witness": "0x1111111111111111111111111111111111111111111111111111111111111111", + } + `) + }) +}) + describe('wallet_connect_strict.parameters: auth', () => { test('accepts string shorthand', () => { expect( diff --git a/src/core/zod/rpc.ts b/src/core/zod/rpc.ts index fb9fa11d..d768dec8 100644 --- a/src/core/zod/rpc.ts +++ b/src/core/zod/rpc.ts @@ -81,8 +81,10 @@ const keyAuthorizationRpc = z.object({ ), ), address: z.optional(u.address()), + account: z.optional(u.address()), chainId: u.bigint(), expiry: z.union([u.number(), z.null(), z.undefined()]), + isAdmin: z.optional(z.boolean()), keyId: u.address(), keyType, limits: z.optional( @@ -91,6 +93,7 @@ const keyAuthorizationRpc = z.object({ ), ), signature: signatureEnvelope, + witness: z.optional(u.hex()), }) as z.ZodMiniType< KeyAuthorizationRpcDecoded, KeyAuthorization.Rpc & { address?: KeyAuthorization.Rpc['keyId'] | undefined } @@ -103,6 +106,7 @@ export const keyAuthorization = z.codec(keyAuthorizationRpc, z.custom ({ token, limit: Hex.fromNumber(limit), @@ -124,8 +128,12 @@ export const keyAuthorization = z.codec(keyAuthorizationRpc, z.custom ({ token, limit: Hex.toBigInt(limit), @@ -156,6 +168,7 @@ export const keyAuthorization = z.codec(keyAuthorizationRpc, z.custom