diff --git a/packages/composable/README.md b/packages/composable/README.md index 9a73bd87d..c329e307d 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -119,6 +119,27 @@ const signedRegisterCalldata = poller.encodeRegisterWithSignature(schedule, dead // Direct revocation must be submitted by schedule.funder. const revokeCalldata = poller.encodeRevoke(schedule) + +// Or authorize revocation for submission by another account. +const revokeAuthorization = { + handler: schedule.handler, + authEpoch: schedule.authEpoch, + funder: schedule.funder, + owner: schedule.owner, + salt: schedule.salt, +} +const revokeDeadline = Math.floor(Date.now() / 1000) + 15 * 60 +const revokeTypedData = poller.getRevokeTypedData({ + chainId, + ...revokeAuthorization, + deadline: revokeDeadline, +}) +const revokeSignature = await adapter.signer.signTypedData( + revokeTypedData.domain, + revokeTypedData.types, + revokeTypedData.message, +) +const signedRevokeCalldata = poller.encodeRevokeWithSignature(revokeAuthorization, revokeDeadline, revokeSignature) ``` The schedule fields are: @@ -130,7 +151,7 @@ The schedule fields are: - `salt`: the registered conditional order's salt. - `staticInput`: the registered conditional order's encoded static input. -The SDK only encodes these transactions; the consumer owns signing, gas policy, submission, and confirmation. Submit direct registration and revocation calldata to `pollerAddress` from `schedule.funder`, or submit `signedRegisterCalldata` through a relayer. Signed registration calldata contains the schedule, deadline, and signature. Replay protection is scoped to the schedule ID through `authEpoch`. Use `poller.getComposableCowAddress()` and `poller.getSchedule(scheduleId)` to read Poller state. +The SDK only encodes these transactions; the consumer owns signing, gas policy, submission, and confirmation. Submit direct registration and revocation calldata to `pollerAddress` from `schedule.funder`, or submit `signedRegisterCalldata` or `signedRevokeCalldata` through a relayer. Signed registration calldata contains the schedule, deadline, and signature. Signed revocation calldata contains the handler, funder, owner, salt, `authEpoch`, deadline, and signature. Replay protection is scoped to the schedule ID through `authEpoch`. Use `poller.getComposableCowAddress()` and `poller.getSchedule(scheduleId)` to read Poller state. ## Usage diff --git a/packages/composable/src/ComposableCowPoller.ts b/packages/composable/src/ComposableCowPoller.ts index cd40d35cc..3a90b24b9 100644 --- a/packages/composable/src/ComposableCowPoller.ts +++ b/packages/composable/src/ComposableCowPoller.ts @@ -4,7 +4,9 @@ import { ComposableCowPollerAbi } from './abis/ComposableCowPollerAbi' import type { ComposableCowPollerDirectRevoke, ComposableCowPollerRegisterTypedDataParams, + ComposableCowPollerRevokeTypedDataParams, ComposableCowPollerSchedule, + ComposableCowPollerScheduleAuthorization, ComposableCowPollerScheduleKey, ComposableCowPollerTypedData, } from './types' @@ -21,6 +23,16 @@ const REGISTER_TYPES = { { name: 'deadline', type: 'uint256' }, ], } +const REVOKE_TYPES = { + Revoke: [ + { name: 'handler', type: 'address' }, + { name: 'authEpoch', type: 'uint96' }, + { name: 'funder', type: 'address' }, + { name: 'owner', type: 'address' }, + { name: 'salt', type: 'bytes32' }, + { name: 'deadline', type: 'uint256' }, + ], +} type RegisterMessage = { readonly handler: string @@ -32,6 +44,15 @@ type RegisterMessage = { readonly deadline: BigIntish } +type RevokeMessage = { + readonly handler: string + readonly authEpoch: BigIntish + readonly funder: string + readonly owner: string + readonly salt: string + readonly deadline: BigIntish +} + /** Utilities for interacting with a ComposableCowPoller deployment. */ export class ComposableCowPoller { constructor(public readonly pollerAddress?: string) {} @@ -108,6 +129,24 @@ export class ComposableCowPoller { } } + /** Builds the EIP-712 payload authorized by revokeWithSignature. */ + public getRevokeTypedData({ + chainId, + handler, + authEpoch, + funder, + owner, + salt, + deadline, + }: ComposableCowPollerRevokeTypedDataParams): ComposableCowPollerTypedData<'Revoke', RevokeMessage> { + return { + domain: this.getEip712Domain(chainId), + types: REVOKE_TYPES, + primaryType: 'Revoke', + message: { handler, authEpoch, funder, owner, salt, deadline }, + } + } + /** Returns the app-data-independent schedule ID. */ public getScheduleId(schedule: ComposableCowPollerScheduleKey): string { const encoded = getGlobalAdapter().utils.encodeAbi(SCHEDULE_ID_ABI, [ @@ -147,4 +186,21 @@ export class ComposableCowPoller { public encodeRevoke({ handler, owner, salt }: ComposableCowPollerDirectRevoke): string { return getGlobalAdapter().utils.encodeFunction(ComposableCowPollerAbi, 'revoke', [handler, owner, salt]) as string } + + /** Encodes Poller.revokeWithSignature. */ + public encodeRevokeWithSignature( + { handler, funder, owner, salt, authEpoch }: ComposableCowPollerScheduleAuthorization, + deadline: BigIntish, + signature: string, + ): string { + return getGlobalAdapter().utils.encodeFunction(ComposableCowPollerAbi, 'revokeWithSignature', [ + handler, + funder, + owner, + salt, + authEpoch, + deadline, + signature, + ]) as string + } } diff --git a/packages/composable/src/abis/ComposableCowPollerAbi.ts b/packages/composable/src/abis/ComposableCowPollerAbi.ts index 866537955..3b28868b1 100644 --- a/packages/composable/src/abis/ComposableCowPollerAbi.ts +++ b/packages/composable/src/abis/ComposableCowPollerAbi.ts @@ -60,6 +60,21 @@ export const ComposableCowPollerAbi = [ outputs: ID_OUTPUT, stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'revokeWithSignature', + inputs: [ + { name: 'handler', type: 'address', internalType: 'contract IConditionalOrderGenerator' }, + { name: 'funder', type: 'address', internalType: 'address' }, + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'salt', type: 'bytes32', internalType: 'bytes32' }, + { name: 'authEpoch', type: 'uint96', internalType: 'uint96' }, + { name: 'deadline', type: 'uint256', internalType: 'uint256' }, + { name: 'signature', type: 'bytes', internalType: 'bytes' }, + ], + outputs: ID_OUTPUT, + stateMutability: 'nonpayable', + }, { type: 'function', name: 'schedules', diff --git a/packages/composable/src/types.ts b/packages/composable/src/types.ts index 9e28e868b..bace04f5a 100644 --- a/packages/composable/src/types.ts +++ b/packages/composable/src/types.ts @@ -115,6 +115,11 @@ export type ComposableCowPollerRegisterTypedDataParams = { readonly deadline: BigIntish } +export type ComposableCowPollerRevokeTypedDataParams = ComposableCowPollerScheduleAuthorization & { + readonly chainId: number + readonly deadline: BigIntish +} + export enum ProofLocation { // The location of the proofs is private to the caller. PRIVATE = 0, diff --git a/packages/composable/tests/ComposableCowPoller.spec.ts b/packages/composable/tests/ComposableCowPoller.spec.ts index f4795e044..caa6396be 100644 --- a/packages/composable/tests/ComposableCowPoller.spec.ts +++ b/packages/composable/tests/ComposableCowPoller.spec.ts @@ -2,7 +2,12 @@ import { setGlobalAdapter } from '@cowprotocol/sdk-common' import { BigNumber } from 'ethers-v5' import * as composable from '../src' -import { ComposableCowPoller, type ComposableCowPollerDirectRevoke, type ComposableCowPollerSchedule } from '../src' +import { + ComposableCowPoller, + type ComposableCowPollerDirectRevoke, + type ComposableCowPollerSchedule, + type ComposableCowPollerScheduleAuthorization, +} from '../src' import { ComposableCowPollerAbi } from '../src/abis/ComposableCowPollerAbi' import { createAdapters } from './setup' @@ -22,9 +27,18 @@ const CHAIN_ID = 1 const REGISTER_SELECTOR = '0x199d771f' const REGISTER_WITH_SIGNATURE_SELECTOR = '0x9e72edd4' const REVOKE_SELECTOR = '0xd96054c4' +const REVOKE_WITH_SIGNATURE_SELECTOR = '0xbfd83f22' const DEADLINE = 2_000_000_000n const SIGNATURE = '0x123456' const REGISTER_DIGEST = '0x7abf30523ae51092914ae8230bc2af45078d9b4f47062f3c9cb6e39cd13106bf' +const REVOKE_DIGEST = '0x11b6cb77364f3c3f8a454cb36f0d22166c6acd9575c066b309f95c9aff712c5f' +const AUTHORIZATION: ComposableCowPollerScheduleAuthorization = { + handler: SCHEDULE.handler, + authEpoch: SCHEDULE.authEpoch, + funder: SCHEDULE.funder, + owner: SCHEDULE.owner, + salt: SCHEDULE.salt, +} describe('ComposableCowPoller ABI', () => { test('keeps the ABI internal', () => { @@ -38,6 +52,7 @@ describe('ComposableCowPoller ABI', () => { 'register', 'registerWithSignature', 'revoke', + 'revokeWithSignature', 'schedules', ]) }) @@ -171,6 +186,13 @@ describe('ComposableCowPoller', () => { expect(() => instance.getRegisterTypedData({ chainId: CHAIN_ID, schedule: SCHEDULE, deadline: DEADLINE })).toThrow( 'pollerAddress is required', ) + expect(() => + instance.getRevokeTypedData({ + chainId: CHAIN_ID, + ...AUTHORIZATION, + deadline: DEADLINE, + }), + ).toThrow('pollerAddress is required') }) test('builds the register digest across adapters', () => { @@ -213,4 +235,41 @@ describe('ComposableCowPoller', () => { expect(adapters.viemAdapter.utils.toBigIntish(deadline)).toEqual(DEADLINE) expect(signature).toEqual(SIGNATURE) }) + + test('builds the revoke digest across adapters', () => { + for (const adapter of Object.values(adapters)) { + setGlobalAdapter(adapter) + const typedData = poller.getRevokeTypedData({ + chainId: CHAIN_ID, + ...AUTHORIZATION, + deadline: DEADLINE, + }) + + expect(typedData.primaryType).toEqual('Revoke') + expect(adapter.utils.hashTypedData(typedData.domain, typedData.types, typedData.message)).toEqual(REVOKE_DIGEST) + } + }) + + test('encodes signed revocation calldata across adapters', () => { + const encodedCalls = [] + + for (const adapter of Object.values(adapters)) { + setGlobalAdapter(adapter) + encodedCalls.push(poller.encodeRevokeWithSignature(AUTHORIZATION, DEADLINE, SIGNATURE)) + } + + expect(new Set(encodedCalls).size).toEqual(1) + expect(encodedCalls[0]?.slice(0, 10)).toEqual(REVOKE_WITH_SIGNATURE_SELECTOR) + + const [handler, funder, owner, salt, authEpoch, deadline, signature] = + adapters.viemAdapter.utils.decodeFunctionData(ComposableCowPollerAbi, 'revokeWithSignature', encodedCalls[0]!) + + expect(handler.toLowerCase()).toEqual(AUTHORIZATION.handler) + expect(funder.toLowerCase()).toEqual(AUTHORIZATION.funder) + expect(owner.toLowerCase()).toEqual(AUTHORIZATION.owner) + expect(salt).toEqual(AUTHORIZATION.salt) + expect(authEpoch).toEqual(AUTHORIZATION.authEpoch) + expect(deadline).toEqual(DEADLINE) + expect(signature).toEqual(SIGNATURE) + }) })