diff --git a/packages/api-kit/src/SafeApiKit.ts b/packages/api-kit/src/SafeApiKit.ts index b79188e68..349420324 100644 --- a/packages/api-kit/src/SafeApiKit.ts +++ b/packages/api-kit/src/SafeApiKit.ts @@ -1,10 +1,13 @@ import { AddMessageProps, AddSafeDelegateProps, + AddSafeOperationProps, AllTransactionsListResponse, AllTransactionsOptions, DeleteSafeDelegateProps, GetSafeDelegateProps, + GetSafeOperationListProps, + GetSafeOperationListResponse, SafeSingletonResponse, GetSafeMessageListProps, ModulesResponse, @@ -20,6 +23,7 @@ import { SafeMultisigTransactionEstimate, SafeMultisigTransactionEstimateResponse, SafeMultisigTransactionListResponse, + SafeOperationResponse, SafeServiceInfoResponse, SignatureResponse, TokenInfoListResponse, @@ -35,6 +39,7 @@ import { SafeMultisigTransactionResponse } from '@safe-global/safe-core-sdk-types' import { TRANSACTION_SERVICE_URLS } from './utils/config' +import { isEmptyData } from './utils' export interface SafeApiKitConfig { /** chainId - The chainId */ @@ -96,7 +101,7 @@ class SafeApiKit { */ async getServiceSingletonsInfo(): Promise { return sendRequest({ - url: `${this.#txServiceBaseUrl}/v1/about/master-copies`, + url: `${this.#txServiceBaseUrl}/v1/about/singletons`, method: HttpMethod.Get }) } @@ -714,6 +719,128 @@ class SafeApiKit { } }) } + + /** + * Get the SafeOperations that were sent from a particular address. + * @param getSafeOperationsProps - The parameters to filter the list of SafeOperations + * @throws "Safe address must not be empty" + * @throws "Invalid Ethereum address {safeAddress}" + * @returns The SafeOperations sent from the given Safe's address + */ + async getSafeOperationsByAddress({ + safeAddress, + ordering, + limit, + offset + }: GetSafeOperationListProps): Promise { + if (!safeAddress) { + throw new Error('Safe address must not be empty') + } + + const { address } = this.#getEip3770Address(safeAddress) + + const url = new URL(`${this.#txServiceBaseUrl}/v1/safes/${address}/safe-operations/`) + + if (ordering) { + url.searchParams.set('ordering', ordering) + } + + if (limit) { + url.searchParams.set('limit', limit) + } + + if (offset) { + url.searchParams.set('offset', offset) + } + + return sendRequest({ + url: url.toString(), + method: HttpMethod.Get + }) + } + + /** + * Get a SafeOperation by its hash. + * @param safeOperationHash The SafeOperation hash + * @throws "SafeOperation hash must not be empty" + * @throws "Not found." + * @returns The SafeOperation + */ + async getSafeOperation(safeOperationHash: string): Promise { + if (!safeOperationHash) { + throw new Error('SafeOperation hash must not be empty') + } + + return sendRequest({ + url: `${this.#txServiceBaseUrl}/v1/safe-operations/${safeOperationHash}/`, + method: HttpMethod.Get + }) + } + + /** + * Create a new 4337 SafeOperation for a Safe. + * @param addSafeOperationProps - The configuration of the SafeOperation + * @throws "Safe address must not be empty" + * @throws "Invalid Safe address {safeAddress}" + * @throws "Module address must not be empty" + * @throws "Invalid module address {moduleAddress}" + * @throws "Signature must not be empty" + */ + async addSafeOperation({ + entryPoint, + moduleAddress: moduleAddressProp, + options, + safeAddress: safeAddressProp, + userOperation + }: AddSafeOperationProps): Promise { + let safeAddress: string, moduleAddress: string + + if (!safeAddressProp) { + throw new Error('Safe address must not be empty') + } + try { + safeAddress = this.#getEip3770Address(safeAddressProp).address + } catch (err) { + throw new Error(`Invalid Safe address ${safeAddressProp}`) + } + + if (!moduleAddressProp) { + throw new Error('Module address must not be empty') + } + + try { + moduleAddress = this.#getEip3770Address(moduleAddressProp).address + } catch (err) { + throw new Error(`Invalid module address ${moduleAddressProp}`) + } + + if (isEmptyData(userOperation.signature)) { + throw new Error('Signature must not be empty') + } + + return sendRequest({ + url: `${this.#txServiceBaseUrl}/v1/safes/${safeAddress}/safe-operations/`, + method: HttpMethod.Post, + body: { + nonce: Number(userOperation.nonce), + initCode: isEmptyData(userOperation.initCode) ? null : userOperation.initCode, + callData: userOperation.callData, + callDataGasLimit: userOperation.callGasLimit.toString(), + verificationGasLimit: userOperation.verificationGasLimit.toString(), + preVerificationGas: userOperation.preVerificationGas.toString(), + maxFeePerGas: userOperation.maxFeePerGas.toString(), + maxPriorityFeePerGas: userOperation.maxPriorityFeePerGas.toString(), + paymasterAndData: isEmptyData(userOperation.paymasterAndData) + ? null + : userOperation.paymasterAndData, + entryPoint, + validAfter: !options?.validAfter ? null : options?.validAfter, + validUntil: !options?.validUntil ? null : options?.validUntil, + signature: userOperation.signature, + moduleAddress + } + }) + } } export default SafeApiKit diff --git a/packages/api-kit/src/types/safeTransactionServiceTypes.ts b/packages/api-kit/src/types/safeTransactionServiceTypes.ts index 92ee2b70b..d96bf883e 100644 --- a/packages/api-kit/src/types/safeTransactionServiceTypes.ts +++ b/packages/api-kit/src/types/safeTransactionServiceTypes.ts @@ -1,7 +1,8 @@ import { Signer, TypedDataDomain, TypedDataField } from 'ethers' import { SafeMultisigTransactionResponse, - SafeTransactionData + SafeTransactionData, + UserOperation } from '@safe-global/safe-core-sdk-types' export type SafeServiceInfoResponse = { @@ -287,3 +288,77 @@ export type EIP712TypedData = { types: TypedDataField message: Record } + +export type SafeOperationConfirmation = { + readonly created: string + readonly modified: string + readonly owner: string + readonly signature: string + readonly signatureType: string +} + +export type UserOperationResponse = { + readonly ethereumTxHash: string + readonly sender: string + readonly userOperationHash: string + readonly nonce: number + readonly initCode: null | string + readonly callData: null | string + readonly callDataGasLimit: number + readonly verificationGasLimit: number + readonly preVerificationGas: number + readonly maxFeePerGas: number + readonly maxPriorityFeePerGas: number + readonly paymaster: null | string + readonly paymasterData: null | string + readonly signature: string + readonly entryPoint: string +} + +export type SafeOperationResponse = { + readonly created: string + readonly modified: string + readonly safeOperationHash: string + readonly validAfter: string + readonly validUntil: string + readonly moduleAddress: string + readonly confirmations?: Array + readonly preparedSignature?: string + readonly userOperation?: UserOperationResponse +} + +export type GetSafeOperationListProps = { + /** Address of the Safe to get SafeOperations for */ + safeAddress: string + /** Which field to use when ordering the results */ + ordering?: string + /** Maximum number of results to return per page */ + limit?: string + /** Initial index from which to return the results */ + offset?: string +} + +export type GetSafeOperationListResponse = { + readonly count: number + readonly next?: string + readonly previous?: string + readonly results: Array +} + +export type AddSafeOperationProps = { + /** Address of the EntryPoint contract */ + entryPoint: string + /** Address of the Safe4337Module contract */ + moduleAddress: string + /** Address of the Safe to add a SafeOperation for */ + safeAddress: string + /** UserOperation object to add */ + userOperation: UserOperation + /** Options object */ + options?: { + /** The UserOperation will remain valid until this block's timestamp */ + validUntil?: number + /** The UserOperation will be valid after this block's timestamp */ + validAfter?: number + } +} diff --git a/packages/api-kit/src/utils/constants.ts b/packages/api-kit/src/utils/constants.ts new file mode 100644 index 000000000..c403b44cb --- /dev/null +++ b/packages/api-kit/src/utils/constants.ts @@ -0,0 +1 @@ +export const EMPTY_DATA = '0x' diff --git a/packages/api-kit/src/utils/index.ts b/packages/api-kit/src/utils/index.ts new file mode 100644 index 000000000..9160cbb7e --- /dev/null +++ b/packages/api-kit/src/utils/index.ts @@ -0,0 +1,3 @@ +import { EMPTY_DATA } from './constants' + +export const isEmptyData = (input: string) => !input || input === EMPTY_DATA diff --git a/packages/api-kit/tests/e2e/addSafeOperation.test.ts b/packages/api-kit/tests/e2e/addSafeOperation.test.ts new file mode 100644 index 000000000..41997ddcc --- /dev/null +++ b/packages/api-kit/tests/e2e/addSafeOperation.test.ts @@ -0,0 +1,193 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { ethers } from 'ethers' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import { SafeOperation } from '@safe-global/safe-core-sdk-types' +import Safe from '@safe-global/protocol-kit' +import SafeApiKit from '@safe-global/api-kit' +import { Safe4337Pack } from '@safe-global/relay-kit' +import { generateTransferCallData } from '@safe-global/relay-kit/src/packs/safe-4337/testing-utils/helpers' +import { RPC_4337_CALLS } from '@safe-global/relay-kit/packs/safe-4337/constants' +import { getSafe4337ModuleDeployment } from '@safe-global/safe-modules-deployments' +import config from '../utils/config' +import { getKits } from '../utils/setupKits' + +chai.use(chaiAsPromised) +chai.use(sinonChai) + +const SIGNER_PK = '0x83a415ca62e11f5fa5567e98450d0f82ae19ff36ef876c10a8d448c788a53676' +const SAFE_ADDRESS = '0x60C4Ab82D06Fd7dFE9517e17736C2Dcc77443EF0' // 1/1 Safe (v1.4.1) with signer above as owner + 4337 module enabled +const PAYMASTER_TOKEN_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' +const PAYMASTER_ADDRESS = '0x0000000000325602a77416A16136FDafd04b299f' +const BUNDLER_URL = `https://bundler.url` +const TX_SERVICE_URL = 'https://safe-transaction-sepolia.staging.5afe.dev/api' + +let safeApiKit: SafeApiKit +let protocolKit: Safe +let safe4337Pack: Safe4337Pack +let moduleAddress: string + +describe('addSafeOperation', () => { + const transferUSDC = { + to: PAYMASTER_TOKEN_ADDRESS, + data: generateTransferCallData(SAFE_ADDRESS, 100_000n), + value: '0', + operation: 0 + } + + // Setup mocks for the bundler client + const providerStub = sinon.stub(ethers.JsonRpcProvider.prototype, 'send') + + providerStub.withArgs(RPC_4337_CALLS.CHAIN_ID, []).returns(Promise.resolve('0xaa36a7')) + providerStub + .withArgs(RPC_4337_CALLS.SUPPORTED_ENTRY_POINTS, []) + .returns(Promise.resolve(['0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'])) + providerStub + .withArgs('pimlico_getUserOperationGasPrice', []) + .returns( + Promise.resolve({ fast: { maxFeePerGas: '0x3b9aca00', maxPriorityFeePerGas: '0x3b9aca00' } }) + ) + providerStub.withArgs(RPC_4337_CALLS.ESTIMATE_USER_OPERATION_GAS, sinon.match.any).returns( + Promise.resolve({ + preVerificationGas: BigInt(Date.now()), + callGasLimit: BigInt(Date.now()), + verificationGasLimit: BigInt(Date.now()) + }) + ) + + providerStub.callThrough() + + before(async () => { + ;({ safeApiKit, protocolKit } = await getKits({ + safeAddress: SAFE_ADDRESS, + signer: SIGNER_PK, + txServiceUrl: TX_SERVICE_URL + })) + + safe4337Pack = await Safe4337Pack.init({ + provider: protocolKit.getSafeProvider().provider, + signer: protocolKit.getSafeProvider().signer, + options: { safeAddress: SAFE_ADDRESS }, + rpcUrl: config.JSON_RPC, + bundlerUrl: BUNDLER_URL, + paymasterOptions: { + paymasterTokenAddress: PAYMASTER_TOKEN_ADDRESS, + paymasterAddress: PAYMASTER_ADDRESS + } + }) + + const chainId = (await protocolKit.getSafeProvider().getChainId()).toString() + + moduleAddress = getSafe4337ModuleDeployment({ + released: true, + version: '0.2.0', + network: chainId + })?.networkAddresses[chainId] as string + }) + + const getAddSafeOperationProps = async (safeOperation: SafeOperation) => { + const userOperation = safeOperation.toUserOperation() + userOperation.signature = safeOperation.encodedSignatures() + return { + entryPoint: safeOperation.data.entryPoint, + moduleAddress, + safeAddress: SAFE_ADDRESS, + userOperation, + options: { + validAfter: safeOperation.data.validAfter, + validUntil: safeOperation.data.validUntil + } + } + } + + describe('should fail', () => { + it('if safeAddress is empty', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation) + const addSafeOperationProps = await getAddSafeOperationProps(signedSafeOperation) + + await chai + .expect( + safeApiKit.addSafeOperation({ + ...addSafeOperationProps, + safeAddress: '' + }) + ) + .to.be.rejectedWith('Safe address must not be empty') + }) + + it('if safeAddress is invalid', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation) + const addSafeOperationProps = await getAddSafeOperationProps(signedSafeOperation) + + await chai + .expect( + safeApiKit.addSafeOperation({ + ...addSafeOperationProps, + safeAddress: '0x123' + }) + ) + .to.be.rejectedWith('Invalid Safe address 0x123') + }) + + it('if moduleAddress is empty', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation) + const addSafeOperationProps = await getAddSafeOperationProps(signedSafeOperation) + + await chai + .expect( + safeApiKit.addSafeOperation({ + ...addSafeOperationProps, + moduleAddress: '' + }) + ) + .to.be.rejectedWith('Module address must not be empty') + }) + + it('if moduleAddress is invalid', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation) + const addSafeOperationProps = await getAddSafeOperationProps(signedSafeOperation) + + await chai + .expect( + safeApiKit.addSafeOperation({ + ...addSafeOperationProps, + moduleAddress: '0x234' + }) + ) + .to.be.rejectedWith('Invalid module address 0x234') + }) + + it('if the SafeOperation is not signed', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const addSafeOperationProps = await getAddSafeOperationProps(safeOperation) + + await chai + .expect(safeApiKit.addSafeOperation(addSafeOperationProps)) + .to.be.rejectedWith('Signature must not be empty') + }) + }) + + it('should add a new SafeOperation', async () => { + const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] }) + const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation) + const addSafeOperationProps = await getAddSafeOperationProps(signedSafeOperation) + + // Get the number of SafeOperations before adding a new one + const safeOperationsBefore = await safeApiKit.getSafeOperationsByAddress({ + safeAddress: SAFE_ADDRESS + }) + const initialNumSafeOperations = safeOperationsBefore.results.length + + await chai.expect(safeApiKit.addSafeOperation(addSafeOperationProps)).to.be.fulfilled + + const safeOperationsAfter = await safeApiKit.getSafeOperationsByAddress({ + safeAddress: SAFE_ADDRESS + }) + chai.expect(safeOperationsAfter.results.length).to.equal(initialNumSafeOperations + 1) + }) +}) diff --git a/packages/api-kit/tests/e2e/getSafeOperation.test.ts b/packages/api-kit/tests/e2e/getSafeOperation.test.ts new file mode 100644 index 000000000..3706dc122 --- /dev/null +++ b/packages/api-kit/tests/e2e/getSafeOperation.test.ts @@ -0,0 +1,42 @@ +import SafeApiKit from '@safe-global/api-kit' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { getApiKit } from '../utils/setupKits' + +chai.use(chaiAsPromised) + +const SAFE_ADDRESS = '0x60C4Ab82D06Fd7dFE9517e17736C2Dcc77443EF0' // v1.4.1 +const TX_SERVICE_URL = 'https://safe-transaction-sepolia.staging.5afe.dev/api' + +let safeApiKit: SafeApiKit + +describe('getSafeOperation', () => { + before(async () => { + safeApiKit = getApiKit(TX_SERVICE_URL) + }) + + describe('should fail', () => { + it('should fail if safeOperationHash is empty', async () => { + await chai + .expect(safeApiKit.getSafeOperation('')) + .to.be.rejectedWith('SafeOperation hash must not be empty') + }) + + it('should fail if safeOperationHash is invalid', async () => { + await chai.expect(safeApiKit.getSafeOperation('0x123')).to.be.rejectedWith('Not found.') + }) + }) + + it('should get the SafeOperation', async () => { + const safeOperations = await safeApiKit.getSafeOperationsByAddress({ + safeAddress: SAFE_ADDRESS + }) + chai.expect(safeOperations.results.length).to.have.above(0) + + const safeOperationHash = safeOperations.results[0].safeOperationHash + + const safeOperation = await safeApiKit.getSafeOperation(safeOperationHash) + + chai.expect(safeOperation).to.deep.eq(safeOperations.results[0]) + }) +}) diff --git a/packages/api-kit/tests/e2e/getSafeOperationsByAddress.test.ts b/packages/api-kit/tests/e2e/getSafeOperationsByAddress.test.ts new file mode 100644 index 000000000..addc2c1be --- /dev/null +++ b/packages/api-kit/tests/e2e/getSafeOperationsByAddress.test.ts @@ -0,0 +1,68 @@ +import SafeApiKit from '@safe-global/api-kit' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { getApiKit } from '../utils/setupKits' + +chai.use(chaiAsPromised) + +const SAFE_ADDRESS = '0x60C4Ab82D06Fd7dFE9517e17736C2Dcc77443EF0' // v1.4.1 +const TX_SERVICE_URL = 'https://safe-transaction-sepolia.staging.5afe.dev/api' + +let safeApiKit: SafeApiKit + +describe('getSafeOperationsByAddress', () => { + before(async () => { + safeApiKit = getApiKit(TX_SERVICE_URL) + }) + + describe('should fail', () => { + it('should fail if safeAddress is empty', async () => { + await chai + .expect(safeApiKit.getSafeOperationsByAddress({ safeAddress: '' })) + .to.be.rejectedWith('Safe address must not be empty') + }) + + it('should fail if safeAddress is invalid', async () => { + await chai + .expect(safeApiKit.getSafeOperationsByAddress({ safeAddress: '0x123' })) + .to.be.rejectedWith('Invalid Ethereum address 0x123') + }) + }) + + it('should get the SafeOperation list', async () => { + const safeOperations = await safeApiKit.getSafeOperationsByAddress({ + safeAddress: SAFE_ADDRESS + }) + + chai.expect(safeOperations).to.have.property('count').greaterThan(1) + chai.expect(safeOperations).to.have.property('results').to.be.an('array') + + safeOperations.results.every((safeOperation) => { + chai.expect(safeOperation).to.have.property('created') + chai.expect(safeOperation).to.have.property('modified') + chai.expect(safeOperation).to.have.property('safeOperationHash') + chai.expect(safeOperation).to.have.property('validAfter') + chai.expect(safeOperation).to.have.property('validUntil') + chai.expect(safeOperation).to.have.property('moduleAddress') + chai.expect(safeOperation).to.have.property('confirmations').to.be.an('array') + chai.expect(safeOperation).to.have.property('preparedSignature') + chai.expect(safeOperation).to.have.property('userOperation') + + chai.expect(safeOperation.userOperation).to.have.property('ethereumTxHash') + chai.expect(safeOperation.userOperation).to.have.property('sender').to.eq(SAFE_ADDRESS) + chai.expect(safeOperation.userOperation).to.have.property('userOperationHash') + chai.expect(safeOperation.userOperation).to.have.property('nonce') + chai.expect(safeOperation.userOperation).to.have.property('initCode') + chai.expect(safeOperation.userOperation).to.have.property('callData') + chai.expect(safeOperation.userOperation).to.have.property('callDataGasLimit') + chai.expect(safeOperation.userOperation).to.have.property('verificationGasLimit') + chai.expect(safeOperation.userOperation).to.have.property('preVerificationGas') + chai.expect(safeOperation.userOperation).to.have.property('maxFeePerGas') + chai.expect(safeOperation.userOperation).to.have.property('maxPriorityFeePerGas') + chai.expect(safeOperation.userOperation).to.have.property('paymaster') + chai.expect(safeOperation.userOperation).to.have.property('paymasterData') + chai.expect(safeOperation.userOperation).to.have.property('signature') + chai.expect(safeOperation.userOperation).to.have.property('entryPoint') + }) + }) +}) diff --git a/packages/api-kit/tests/endpoint/index.test.ts b/packages/api-kit/tests/endpoint/index.test.ts index b9a72297e..6dd5ce15f 100644 --- a/packages/api-kit/tests/endpoint/index.test.ts +++ b/packages/api-kit/tests/endpoint/index.test.ts @@ -7,6 +7,7 @@ import SafeApiKit, { } from '@safe-global/api-kit/index' import * as httpRequests from '@safe-global/api-kit/utils/httpRequests' import Safe from '@safe-global/protocol-kit' +import { UserOperation } from '@safe-global/safe-core-sdk-types' import chai from 'chai' import chaiAsPromised from 'chai-as-promised' import sinon from 'sinon' @@ -66,7 +67,7 @@ describe('Endpoint tests', () => { .expect(safeApiKit.getServiceSingletonsInfo()) .to.be.eventually.deep.equals({ data: { success: true } }) chai.expect(fetchData).to.have.been.calledWith({ - url: `${txServiceBaseUrl}/v1/about/master-copies`, + url: `${txServiceBaseUrl}/v1/about/singletons`, method: 'get' }) }) @@ -636,6 +637,82 @@ describe('Endpoint tests', () => { } }) }) + + it('getSafeOperationsByAddress', async () => { + await chai + .expect(safeApiKit.getSafeOperationsByAddress({ safeAddress })) + .to.be.eventually.deep.equals({ data: { success: true } }) + chai.expect(fetchData).to.have.been.calledWith({ + url: `${txServiceBaseUrl}/v1/safes/${safeAddress}/safe-operations/`, + method: 'get' + }) + }) + + it('getSafeOperation', async () => { + const safeOperationHash = 'safe-operation-hash' + + await chai + .expect(safeApiKit.getSafeOperation(safeOperationHash)) + .to.be.eventually.deep.equals({ data: { success: true } }) + chai.expect(fetchData).to.have.been.calledWith({ + url: `${txServiceBaseUrl}/v1/safe-operations/${safeOperationHash}/`, + method: 'get' + }) + }) + + it('addSafeOperation', async () => { + const moduleAddress = '0xa581c4A4DB7175302464fF3C06380BC3270b4037' + + const userOperation: UserOperation = { + sender: safeAddress, + nonce: '42', + initCode: '0xfbc38024f74946d9ec31e0c8658dd65e335c6e57c14575250787ec5fb270c08a', + callData: + '0x7bb374280000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c72380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000060c4ab82d06fd7dfe9517e17736c2dcc77443ef000000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000', + callGasLimit: 150799n, + verificationGasLimit: 200691n, + preVerificationGas: 50943n, + maxFeePerGas: 1949282597n, + maxPriorityFeePerGas: 1380000000n, + paymasterAndData: '0xdff7fa1077bce740a6a212b3995990682c0ba66d', + signature: '0xsignature' + } + + const entryPoint = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789' + const options = { validAfter: 123, validUntil: 234 } + + await chai + .expect( + safeApiKit.addSafeOperation({ + entryPoint, + moduleAddress, + options, + safeAddress, + userOperation + }) + ) + .to.be.eventually.deep.equals({ data: { success: true } }) + + chai.expect(fetchData).to.have.been.calledWith({ + url: `${txServiceBaseUrl}/v1/safes/${safeAddress}/safe-operations/`, + method: 'post', + body: { + nonce: Number(userOperation.nonce), + initCode: userOperation.initCode, + callData: userOperation.callData, + callDataGasLimit: userOperation.callGasLimit.toString(), + verificationGasLimit: userOperation.verificationGasLimit.toString(), + preVerificationGas: userOperation.preVerificationGas.toString(), + maxFeePerGas: userOperation.maxFeePerGas.toString(), + maxPriorityFeePerGas: userOperation.maxPriorityFeePerGas.toString(), + paymasterAndData: userOperation.paymasterAndData, + entryPoint, + ...options, + signature: userOperation.signature, + moduleAddress + } + }) + }) }) describe('Custom endpoint', () => { diff --git a/packages/relay-kit/src/deprecated.ts b/packages/relay-kit/src/deprecated.ts new file mode 100644 index 000000000..6153fe043 --- /dev/null +++ b/packages/relay-kit/src/deprecated.ts @@ -0,0 +1,14 @@ +export type { + /** + * @deprecated Please import { EstimateGasData } from @safe-global/safe-core-sdk-types + */ + EstimateGasData, + /** + * @deprecated Please import { SafeUserOperation } from @safe-global/safe-core-sdk-types + */ + SafeUserOperation, + /** + * @deprecated Please import { UserOperation } from @safe-global/safe-core-sdk-types + */ + UserOperation +} from '@safe-global/safe-core-sdk-types' diff --git a/packages/relay-kit/src/index.ts b/packages/relay-kit/src/index.ts index efa1730a3..47c72b993 100644 --- a/packages/relay-kit/src/index.ts +++ b/packages/relay-kit/src/index.ts @@ -1,3 +1,5 @@ +export * from './deprecated' + export * from './packs/gelato/GelatoRelayPack' export * from './packs/gelato/types' diff --git a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.test.ts b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.test.ts index 808da64d7..212d87d1c 100644 --- a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.test.ts +++ b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.test.ts @@ -6,7 +6,7 @@ import { } from '@safe-global/safe-modules-deployments' import { MetaTransactionData, OperationType } from '@safe-global/safe-core-sdk-types' import { Safe4337Pack } from './Safe4337Pack' -import SafeOperation from './SafeOperation' +import EthSafeOperation from './SafeOperation' import * as constants from './constants' import * as fixtures from './testing-utils/fixtures' import { createSafe4337Pack, generateTransferCallData } from './testing-utils/helpers' @@ -308,7 +308,7 @@ describe('Safe4337Pack', () => { transactions }) - expect(safeOperation).toBeInstanceOf(SafeOperation) + expect(safeOperation).toBeInstanceOf(EthSafeOperation) expect(safeOperation.data).toMatchObject({ safe: fixtures.SAFE_ADDRESS_v1_4_1, entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789', @@ -338,7 +338,7 @@ describe('Safe4337Pack', () => { transactions: [transferUSDC] }) - expect(safeOperation).toBeInstanceOf(SafeOperation) + expect(safeOperation).toBeInstanceOf(EthSafeOperation) expect(safeOperation.data).toMatchObject({ safe: fixtures.SAFE_ADDRESS_v1_4_1, entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789', @@ -397,7 +397,7 @@ describe('Safe4337Pack', () => { transactions: [transferUSDC] }) - expect(sponsoredSafeOperation).toBeInstanceOf(SafeOperation) + expect(sponsoredSafeOperation).toBeInstanceOf(EthSafeOperation) expect(sponsoredSafeOperation.data).toMatchObject({ safe: fixtures.SAFE_ADDRESS_v1_4_1, entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789', @@ -470,7 +470,7 @@ describe('Safe4337Pack', () => { const batch = [transferUSDC, approveTransaction] - expect(sponsoredSafeOperation).toBeInstanceOf(SafeOperation) + expect(sponsoredSafeOperation).toBeInstanceOf(EthSafeOperation) expect(sponsoredSafeOperation.data).toMatchObject({ safe: fixtures.SAFE_ADDRESS_v1_4_1, entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789', diff --git a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts index 5afab37e7..aa5a78a9a 100644 --- a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts +++ b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts @@ -8,21 +8,24 @@ import Safe, { getMultiSendContract } from '@safe-global/protocol-kit' import { RelayKitBasePack } from '@safe-global/relay-kit/RelayKitBasePack' -import { MetaTransactionData, OperationType, SafeSignature } from '@safe-global/safe-core-sdk-types' +import { + MetaTransactionData, + OperationType, + SafeSignature, + UserOperation, + SafeUserOperation +} from '@safe-global/safe-core-sdk-types' import { getAddModulesLibDeployment, getSafe4337ModuleDeployment } from '@safe-global/safe-modules-deployments' - -import SafeOperation from './SafeOperation' +import EthSafeOperation from './SafeOperation' import { EstimateFeeProps, Safe4337CreateTransactionProps, Safe4337ExecutableProps, Safe4337InitOptions, Safe4337Options, - SafeUserOperation, - UserOperation, UserOperationReceipt, UserOperationWithPayload, PaymasterOptions @@ -51,9 +54,9 @@ const MAX_ERC20_AMOUNT_TO_APPROVE = */ export class Safe4337Pack extends RelayKitBasePack<{ EstimateFeeProps: EstimateFeeProps - EstimateFeeResult: SafeOperation + EstimateFeeResult: EthSafeOperation CreateTransactionProps: Safe4337CreateTransactionProps - CreateTransactionResult: SafeOperation + CreateTransactionResult: EthSafeOperation ExecuteTransactionProps: Safe4337ExecutableProps ExecuteTransactionResult: string }> { @@ -267,15 +270,15 @@ export class Safe4337Pack extends RelayKitBasePack<{ * Estimates gas for the SafeOperation. * * @param {EstimateFeeProps} props - The parameters for the gas estimation. - * @param {SafeOperation} props.safeOperation - The SafeOperation to estimate the gas. + * @param {EthSafeOperation} props.safeOperation - The SafeOperation to estimate the gas. * @param {IFeeEstimator} props.feeEstimator - The function to estimate the gas. - * @return {Promise} The Promise object that will be resolved into the gas estimation. + * @return {Promise} The Promise object that will be resolved into the gas estimation. */ async getEstimateFee({ safeOperation, feeEstimator = new PimlicoFeeEstimator() - }: EstimateFeeProps): Promise { + }: EstimateFeeProps): Promise { const setupEstimationData = await feeEstimator?.setupEstimation?.({ bundlerUrl: this.#BUNDLER_URL, entryPoint: this.#ENTRYPOINT_ADDRESS, @@ -337,12 +340,12 @@ export class Safe4337Pack extends RelayKitBasePack<{ * * @param {MetaTransactionData[]} transactions - The transactions to batch in a SafeOperation. * @param options - Optional configuration options for the transaction creation. - * @return {Promise} The Promise object will resolve a SafeOperation. + * @return {Promise} The Promise object will resolve a SafeOperation. */ async createTransaction({ transactions, options = {} - }: Safe4337CreateTransactionProps): Promise { + }: Safe4337CreateTransactionProps): Promise { const safeAddress = await this.protocolKit.getAddress() const nonce = await this.#getAccountNonce(safeAddress) @@ -400,7 +403,7 @@ export class Safe4337Pack extends RelayKitBasePack<{ userOperation.initCode = await this.protocolKit.getInitCode() } - const safeOperation = new SafeOperation(userOperation, { + const safeOperation = new EthSafeOperation(userOperation, { entryPoint: this.#ENTRYPOINT_ADDRESS, validUntil, validAfter @@ -415,14 +418,14 @@ export class Safe4337Pack extends RelayKitBasePack<{ /** * Signs a safe operation. * - * @param {SafeOperation} safeOperation - The SafeOperation to sign. + * @param {EthSafeOperation} safeOperation - The SafeOperation to sign. * @param {SigningMethod} signingMethod - The signing method to use. - * @return {Promise} The Promise object will resolve to the signed SafeOperation. + * @return {Promise} The Promise object will resolve to the signed SafeOperation. */ async signSafeOperation( - safeOperation: SafeOperation, + safeOperation: EthSafeOperation, signingMethod: SigningMethod = SigningMethod.ETH_SIGN_TYPED_DATA_V4 - ): Promise { + ): Promise { const owners = await this.protocolKit.getOwners() const signerAddress = await this.protocolKit.getSafeProvider().getSignerAddress() if (!signerAddress) { @@ -452,7 +455,7 @@ export class Safe4337Pack extends RelayKitBasePack<{ signature = await this.protocolKit.signHash(safeOpHash) } - const signedSafeOperation = new SafeOperation(safeOperation.toUserOperation(), { + const signedSafeOperation = new EthSafeOperation(safeOperation.toUserOperation(), { entryPoint: this.#ENTRYPOINT_ADDRESS, validUntil: safeOperation.data.validUntil, validAfter: safeOperation.data.validAfter @@ -470,7 +473,7 @@ export class Safe4337Pack extends RelayKitBasePack<{ /** * Executes the relay transaction. * - * @param {SafeOperation} safeOperation - The SafeOperation to execute. + * @param {EthSafeOperation} safeOperation - The SafeOperation to execute. * @return {Promise} The user operation hash. */ async executeTransaction({ diff --git a/packages/relay-kit/src/packs/safe-4337/SafeOperation.test.ts b/packages/relay-kit/src/packs/safe-4337/SafeOperation.test.ts index 85f1df10e..048b61527 100644 --- a/packages/relay-kit/src/packs/safe-4337/SafeOperation.test.ts +++ b/packages/relay-kit/src/packs/safe-4337/SafeOperation.test.ts @@ -1,11 +1,11 @@ import { ethers } from 'ethers' import { EthSafeSignature } from '@safe-global/protocol-kit' -import SafeOperation from './SafeOperation' +import EthSafeOperation from './SafeOperation' import * as fixtures from './testing-utils/fixtures' describe('SafeOperation', () => { it('should create a SafeOperation from an UserOperation', () => { - const safeOperation = new SafeOperation(fixtures.USER_OPERATION, { + const safeOperation = new EthSafeOperation(fixtures.USER_OPERATION, { entryPoint: fixtures.ENTRYPOINTS[0] }) @@ -29,7 +29,7 @@ describe('SafeOperation', () => { }) it('should add and retrieve signatures', () => { - const safeOperation = new SafeOperation(fixtures.USER_OPERATION, { + const safeOperation = new EthSafeOperation(fixtures.USER_OPERATION, { entryPoint: fixtures.ENTRYPOINTS[0] }) @@ -44,7 +44,7 @@ describe('SafeOperation', () => { }) it('should encode the signatures', () => { - const safeOperation = new SafeOperation(fixtures.USER_OPERATION, { + const safeOperation = new EthSafeOperation(fixtures.USER_OPERATION, { entryPoint: fixtures.ENTRYPOINTS[0] }) @@ -55,7 +55,7 @@ describe('SafeOperation', () => { }) it('should add estimations', () => { - const safeOperation = new SafeOperation(fixtures.USER_OPERATION, { + const safeOperation = new EthSafeOperation(fixtures.USER_OPERATION, { entryPoint: fixtures.ENTRYPOINTS[0] }) @@ -83,7 +83,7 @@ describe('SafeOperation', () => { }) it('should convert to UserOperation', () => { - const safeOperation = new SafeOperation(fixtures.USER_OPERATION, { + const safeOperation = new EthSafeOperation(fixtures.USER_OPERATION, { entryPoint: fixtures.ENTRYPOINTS[0] }) diff --git a/packages/relay-kit/src/packs/safe-4337/SafeOperation.ts b/packages/relay-kit/src/packs/safe-4337/SafeOperation.ts index e27e6d974..2fa0db5d9 100644 --- a/packages/relay-kit/src/packs/safe-4337/SafeOperation.ts +++ b/packages/relay-kit/src/packs/safe-4337/SafeOperation.ts @@ -1,12 +1,16 @@ import { ethers } from 'ethers' -import { SafeSignature } from '@safe-global/safe-core-sdk-types' +import { + EstimateGasData, + SafeOperation, + SafeSignature, + SafeUserOperation, + UserOperation +} from '@safe-global/safe-core-sdk-types' import { buildSignatureBytes } from '@safe-global/protocol-kit' -import { EstimateGasData, SafeUserOperation, UserOperation } from './types' - type SafeOperationOptions = { entryPoint: string; validAfter?: number; validUntil?: number } -class SafeOperation { +class EthSafeOperation implements SafeOperation { data: SafeUserOperation signatures: Map = new Map() @@ -77,4 +81,4 @@ class SafeOperation { } } -export default SafeOperation +export default EthSafeOperation diff --git a/packages/relay-kit/src/packs/safe-4337/estimators/PimlicoFeeEstimator.ts b/packages/relay-kit/src/packs/safe-4337/estimators/PimlicoFeeEstimator.ts index a61eca986..f00932f43 100644 --- a/packages/relay-kit/src/packs/safe-4337/estimators/PimlicoFeeEstimator.ts +++ b/packages/relay-kit/src/packs/safe-4337/estimators/PimlicoFeeEstimator.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers' +import { EstimateGasData } from '@safe-global/safe-core-sdk-types' import { EstimateFeeFunctionProps, - EstimateGasData, EstimateSponsoredFeeFunctionProps, EstimateSponsoredGasData, IFeeEstimator diff --git a/packages/relay-kit/src/packs/safe-4337/types.ts b/packages/relay-kit/src/packs/safe-4337/types.ts index 95458c849..1b78b3348 100644 --- a/packages/relay-kit/src/packs/safe-4337/types.ts +++ b/packages/relay-kit/src/packs/safe-4337/types.ts @@ -1,7 +1,12 @@ import Safe, { SafeProviderConfig } from '@safe-global/protocol-kit' -import { MetaTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' +import { + EstimateGasData, + MetaTransactionData, + SafeVersion, + UserOperation +} from '@safe-global/safe-core-sdk-types' import { ethers } from 'ethers' -import SafeOperation from './SafeOperation' +import EthSafeOperation from './SafeOperation' type ExistingSafeOptions = { safeAddress: string @@ -59,45 +64,7 @@ export type Safe4337CreateTransactionProps = { } export type Safe4337ExecutableProps = { - executable: SafeOperation -} - -export type SafeUserOperation = { - safe: string - nonce: bigint - initCode: string - callData: string - callGasLimit: bigint - verificationGasLimit: bigint - preVerificationGas: bigint - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - paymasterAndData: string - validAfter: number - validUntil: number - entryPoint: string -} - -export type UserOperation = { - sender: string - nonce: string - initCode: string - callData: string - callGasLimit: bigint - verificationGasLimit: bigint - preVerificationGas: bigint - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - paymasterAndData: string - signature: string -} - -export type EstimateGasData = { - maxFeePerGas?: bigint - maxPriorityFeePerGas?: bigint - preVerificationGas?: bigint - verificationGasLimit?: bigint - callGasLimit?: bigint + executable: EthSafeOperation } export type EstimateSponsoredGasData = { @@ -182,6 +149,6 @@ export interface IFeeEstimator { } export type EstimateFeeProps = { - safeOperation: SafeOperation + safeOperation: EthSafeOperation feeEstimator?: IFeeEstimator } diff --git a/packages/relay-kit/src/packs/safe-4337/utils.ts b/packages/relay-kit/src/packs/safe-4337/utils.ts index c3d132872..1e2db76c2 100644 --- a/packages/relay-kit/src/packs/safe-4337/utils.ts +++ b/packages/relay-kit/src/packs/safe-4337/utils.ts @@ -1,5 +1,5 @@ import { ethers } from 'ethers' -import { UserOperation } from './types' +import { UserOperation } from '@safe-global/safe-core-sdk-types' /** * Gets the EIP-4337 bundler provider. diff --git a/packages/safe-core-sdk-types/src/types.ts b/packages/safe-core-sdk-types/src/types.ts index 62634abf5..919d8e165 100644 --- a/packages/safe-core-sdk-types/src/types.ts +++ b/packages/safe-core-sdk-types/src/types.ts @@ -255,3 +255,51 @@ export interface MetaTransactionOptions { gasToken?: string isSponsored?: boolean } + +export type UserOperation = { + sender: string + nonce: string + initCode: string + callData: string + callGasLimit: bigint + verificationGasLimit: bigint + preVerificationGas: bigint + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + paymasterAndData: string + signature: string +} + +export type SafeUserOperation = { + safe: string + nonce: bigint + initCode: string + callData: string + callGasLimit: bigint + verificationGasLimit: bigint + preVerificationGas: bigint + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + paymasterAndData: string + validAfter: number + validUntil: number + entryPoint: string +} + +export type EstimateGasData = { + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint + preVerificationGas?: bigint + verificationGasLimit?: bigint + callGasLimit?: bigint +} + +export interface SafeOperation { + readonly data: SafeUserOperation + readonly signatures: Map + getSignature(signer: string): SafeSignature | undefined + addSignature(signature: SafeSignature): void + encodedSignatures(): string + addEstimations(estimations: EstimateGasData): void + toUserOperation(): UserOperation +}