diff --git a/packages/guard/src/signer.ts b/packages/guard/src/signer.ts index 6d2bf5d49..f6d60bf61 100644 --- a/packages/guard/src/signer.ts +++ b/packages/guard/src/signer.ts @@ -25,6 +25,12 @@ export class GuardSigner implements signers.SapientSigner { return this.address } + async decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + } + async requestSignature( id: string, _message: BytesLike, diff --git a/packages/signhub/package.json b/packages/signhub/package.json index 83b057498..6d47d8bde 100644 --- a/packages/signhub/package.json +++ b/packages/signhub/package.json @@ -14,6 +14,7 @@ "test:coverage": "nyc yarn test" }, "dependencies": { + "@0xsequence/core": "workspace:*", "ethers": "^5.5.2" }, "peerDependencies": {}, diff --git a/packages/signhub/src/orchestrator.ts b/packages/signhub/src/orchestrator.ts index 7700620bb..5129417c7 100644 --- a/packages/signhub/src/orchestrator.ts +++ b/packages/signhub/src/orchestrator.ts @@ -1,4 +1,5 @@ import { ethers } from "ethers" +import { commons } from "@0xsequence/core" import { isSapientSigner, SapientSigner } from "./signers/signer" import { SignerWrapper } from "./signers/wrapper" @@ -39,10 +40,11 @@ export function isSignerStatusPending(status: SignerStatus): status is SignerSta export const InitialSituation = "Initial" /** - * It orchestrates the signing of a single digest by multiple signers. + * Orchestrates the signing of a single digests and transactions by multiple signers. * It can provide internal visibility of the signing process, and it also * provides the internal signers with additional information about the - * message being signed. + * message being signed. Transaction decoration can be used to ensure on-chain state + * is correctly managed during the signing process. */ export class Orchestrator { private observers: ((status: Status, metadata: Object) => void)[] = [] @@ -82,6 +84,15 @@ export class Orchestrator { ]) } + async decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + for (const signer of this.signers) { + bundle = await signer.decorateTransactions(bundle) + } + return bundle + } + signMessage(args: { candidates?: string[], message: ethers.BytesLike, diff --git a/packages/signhub/src/signers/signer.ts b/packages/signhub/src/signers/signer.ts index 28c7749d3..f3bddc594 100644 --- a/packages/signhub/src/signers/signer.ts +++ b/packages/signhub/src/signers/signer.ts @@ -1,9 +1,20 @@ import { ethers } from "ethers" +import { commons } from "@0xsequence/core" import { Status } from "../orchestrator" export interface SapientSigner { getAddress(): Promise + /** + * Modify the transaction bundle before it is sent. + */ + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise + + /** + * Request a signature from the signer. + */ requestSignature( id: string, message: ethers.BytesLike, @@ -15,6 +26,9 @@ export interface SapientSigner { } ): Promise + /** + * Notify the signer of a status change. + */ notifyStatusChange( id: string, status: Status, diff --git a/packages/signhub/src/signers/wrapper.ts b/packages/signhub/src/signers/wrapper.ts index 6f9dbbe57..4d04a9f8e 100644 --- a/packages/signhub/src/signers/wrapper.ts +++ b/packages/signhub/src/signers/wrapper.ts @@ -1,5 +1,6 @@ import { ethers } from 'ethers' +import { commons } from "@0xsequence/core" import { Status } from '../orchestrator' import { SapientSigner } from './signer' @@ -10,6 +11,12 @@ export class SignerWrapper implements SapientSigner { return this.signer.getAddress() } + async decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle + ): Promise { + return bundle + } + async requestSignature( _id: string, message: ethers.BytesLike, diff --git a/packages/signhub/tests/orchestrator.spec.ts b/packages/signhub/tests/orchestrator.spec.ts index 3cc8c602c..2cd9459b5 100644 --- a/packages/signhub/tests/orchestrator.spec.ts +++ b/packages/signhub/tests/orchestrator.spec.ts @@ -1,6 +1,7 @@ import * as chai from 'chai' import { ethers } from 'ethers' +import { commons } from "@0xsequence/core" import { isSignerStatusPending, isSignerStatusRejected, isSignerStatusSigned, Orchestrator, Status } from '../src' import { SapientSigner } from '../src/signers' @@ -109,6 +110,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return brokenSignerEOA.address }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + throw new Error('This is a broken signer.') + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -189,6 +195,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return rejectSignerEOA.address }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + throw new Error('This is a rejected signer.') + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -241,6 +252,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return '0x1234' }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -269,6 +285,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return '0x1234' }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -301,6 +322,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return '0x1234' }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -331,6 +357,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return '0x5678' }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, @@ -383,6 +414,11 @@ describe('Orchestrator', () => { getAddress: async function (): Promise { return '0x1234' }, + decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle, + ): Promise { + return Promise.resolve(bundle) + }, requestSignature: async function ( id: string, message: ethers.utils.BytesLike, diff --git a/packages/wallet/src/orchestrator/wrapper.ts b/packages/wallet/src/orchestrator/wrapper.ts index 5983f9980..0e40995ce 100644 --- a/packages/wallet/src/orchestrator/wrapper.ts +++ b/packages/wallet/src/orchestrator/wrapper.ts @@ -13,6 +13,12 @@ export class SequenceOrchestratorWrapper implements signers.SapientSigner { return this.wallet.address } + async decorateTransactions( + bundle: commons.transaction.IntendedTransactionBundle + ): Promise { + return this.wallet.decorateTransactions(bundle) + } + async requestSignature( _id: string, message: ethers.utils.BytesLike, diff --git a/packages/wallet/src/wallet.ts b/packages/wallet/src/wallet.ts index 3345569eb..c3ef2c141 100644 --- a/packages/wallet/src/wallet.ts +++ b/packages/wallet/src/wallet.ts @@ -141,7 +141,12 @@ export class Wallet< async decorateTransactions( bundle: commons.transaction.IntendedTransactionBundle ): Promise { - if (await this.reader().isDeployed(this.address)) return bundle + const decorated = await this.orchestrator.decorateTransactions(bundle) + const unchanged = decorated === bundle + + if (unchanged && await this.reader().isDeployed(this.address)) { + return bundle + } const deployTx = this.buildDeployTransaction() @@ -151,12 +156,12 @@ export class Wallet< return { entrypoint: this.context.guestModule, chainId: this.chainId, - intent: bundle.intent, + intent: decorated.intent, transactions: [ ...deployTx.transactions, { - to: bundle.entrypoint, - data: commons.transaction.encodeBundleExecData(bundle), + to: decorated.entrypoint, + data: commons.transaction.encodeBundleExecData(decorated), gasLimit: 0, delegateCall: false, revertOnError: true, diff --git a/packages/wallet/tests/wallet.spec.ts b/packages/wallet/tests/wallet.spec.ts index a0d61e764..99ec1fd54 100644 --- a/packages/wallet/tests/wallet.spec.ts +++ b/packages/wallet/tests/wallet.spec.ts @@ -140,41 +140,75 @@ describe('Wallet (primitive)', () => { return { config, orchestrator } } }, { - name: '1/1 signer (nested)', - signers: async () => { - const nestedSigner = ethers.Wallet.createRandom() - - const nestedConfig = coders.config.fromSimple({ - threshold: 1, - checkpoint: 0, - signers: [{ address: nestedSigner.address, weight: 1 }] - }) + name: '1/1 signer (nested)', + signers: async () => { + const nestedSigner = ethers.Wallet.createRandom() - const nestedOrchestrator = new Orchestrator([nestedSigner]) - const nestedWallet = Wallet.newWallet({ - coders: coders, - context: contexts[version], - config: nestedConfig, - orchestrator: nestedOrchestrator, - chainId: provider.network.chainId, - provider, - relayer - }) + const nestedConfig = coders.config.fromSimple({ + threshold: 1, + checkpoint: 0, + signers: [{ address: nestedSigner.address, weight: 1 }] + }) - await nestedWallet.deploy() - expect(await nestedWallet.reader().isDeployed(nestedWallet.address)).to.be.true + const nestedOrchestrator = new Orchestrator([nestedSigner]) + const nestedWallet = Wallet.newWallet({ + coders: coders, + context: contexts[version], + config: nestedConfig, + orchestrator: nestedOrchestrator, + chainId: provider.network.chainId, + provider, + relayer + }) - const config = coders.config.fromSimple({ - threshold: 1, - checkpoint: 0, - signers: [{ address: nestedWallet.address, weight: 1 }] - }) + await nestedWallet.deploy() + expect(await nestedWallet.reader().isDeployed(nestedWallet.address)).to.be.true + + const config = coders.config.fromSimple({ + threshold: 1, + checkpoint: 0, + signers: [{ address: nestedWallet.address, weight: 1 }] + }) - const orchestrator = new Orchestrator([new SequenceOrchestratorWrapper(nestedWallet)]) + const orchestrator = new Orchestrator([new SequenceOrchestratorWrapper(nestedWallet)]) - return { config, orchestrator } - } - }]).map(({ name, signers }) => { + return { config, orchestrator } + } + }, { + name: '1/1 signer (undeployed nested)', + signers: async () => { + const nestedSigner = ethers.Wallet.createRandom() + + const nestedConfig = coders.config.fromSimple({ + threshold: 1, + checkpoint: 0, + signers: [{ address: nestedSigner.address, weight: 1 }] + }) + + const nestedOrchestrator = new Orchestrator([nestedSigner]) + const nestedWallet = Wallet.newWallet({ + coders: coders, + context: contexts[version], + config: nestedConfig, + orchestrator: nestedOrchestrator, + chainId: provider.network.chainId, + provider, + relayer + }) + + expect(await nestedWallet.reader().isDeployed(nestedWallet.address)).to.be.false + + const config = coders.config.fromSimple({ + threshold: 1, + checkpoint: 0, + signers: [{ address: nestedWallet.address, weight: 1 }] + }) + + const orchestrator = new Orchestrator([new SequenceOrchestratorWrapper(nestedWallet)]) + + return { config, orchestrator } + } + }]).map(({ name, signers }) => { describe(`Using ${name}`, () => { let orchestrator: Orchestrator let config: commons.config.Config @@ -185,30 +219,33 @@ describe('Wallet (primitive)', () => { orchestrator = _orchestrator }) + // Skip this as we cannot validate a message with an undeployed nested wallet + if (name !== '1/1 signer (undeployed nested)') { + it('Should sign and validate a message', async () => { + + const wallet = Wallet.newWallet({ + coders: coders, + context: contexts[version], + config, + orchestrator, + chainId: provider.network.chainId, + provider, + relayer + }) - it('Should sign and validate a message', async () => { - const wallet = Wallet.newWallet({ - coders: coders, - context: contexts[version], - config, - orchestrator, - chainId: provider.network.chainId, - provider, - relayer - }) - - await wallet.deploy() - expect(await wallet.reader().isDeployed(wallet.address)).to.be.true + await wallet.deploy() + expect(await wallet.reader().isDeployed(wallet.address)).to.be.true - const message = ethers.utils.toUtf8Bytes( - `This is a random message: ${ethers.utils.hexlify(ethers.utils.randomBytes(96))}` - ) + const message = ethers.utils.toUtf8Bytes( + `This is a random message: ${ethers.utils.hexlify(ethers.utils.randomBytes(96))}` + ) - const signature = await wallet.signMessage(message) - const digest = ethers.utils.keccak256(message) + const signature = await wallet.signMessage(message) + const digest = ethers.utils.keccak256(message) - expect(await wallet.reader().isValidSignature(wallet.address, digest, signature)).to.be.true - }); + expect(await wallet.reader().isValidSignature(wallet.address, digest, signature)).to.be.true + }); + } // // Run tests for deployed and undeployed wallets diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991a2ce06..65af97ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -652,6 +652,9 @@ importers: packages/signhub: dependencies: + '@0xsequence/core': + specifier: workspace:* + version: link:../core ethers: specifier: ^5.5.2 version: 5.7.2