Skip to content

Commit

Permalink
feat(api-kit): 4337 API functions (#777)
Browse files Browse the repository at this point in the history
* Implement functions to call 4337 api endpoints

* e2e tests for addSafeOperation function

* e2e tests for getSafeOperation function

* e2e tests for getSafeOperationsByAddress function

* Endpoint tests for 4337 api functions

* Move shared types to `safe-core-sdk-types`

* Mock bundler client calls for addSafeOperation endpoint tests

* Use mock data in realistic format for unit test

* Assert length of SafeOperations in the getSafeOperation e2e test

* Extend getSafeOperationsByAddress parameters

* Refactor `addSafeOperation` function params to remove internal coupling with Safe4337 module

* Rename master-copies

* Rename `SafeOperation` class to `EthSafeOperation`

This is for better differentiation with the corresponding interface.

---------

Co-authored-by: Yago Pérez Vázquez <[email protected]>
  • Loading branch information
tmjssz and yagopv authored May 21, 2024
1 parent 12dccb6 commit 71f978e
Show file tree
Hide file tree
Showing 18 changed files with 706 additions and 82 deletions.
129 changes: 128 additions & 1 deletion packages/api-kit/src/SafeApiKit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
AddMessageProps,
AddSafeDelegateProps,
AddSafeOperationProps,
AllTransactionsListResponse,
AllTransactionsOptions,
DeleteSafeDelegateProps,
GetSafeDelegateProps,
GetSafeOperationListProps,
GetSafeOperationListResponse,
SafeSingletonResponse,
GetSafeMessageListProps,
ModulesResponse,
Expand All @@ -20,6 +23,7 @@ import {
SafeMultisigTransactionEstimate,
SafeMultisigTransactionEstimateResponse,
SafeMultisigTransactionListResponse,
SafeOperationResponse,
SafeServiceInfoResponse,
SignatureResponse,
TokenInfoListResponse,
Expand All @@ -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 */
Expand Down Expand Up @@ -96,7 +101,7 @@ class SafeApiKit {
*/
async getServiceSingletonsInfo(): Promise<SafeSingletonResponse[]> {
return sendRequest({
url: `${this.#txServiceBaseUrl}/v1/about/master-copies`,
url: `${this.#txServiceBaseUrl}/v1/about/singletons`,
method: HttpMethod.Get
})
}
Expand Down Expand Up @@ -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<GetSafeOperationListResponse> {
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<SafeOperationResponse> {
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<void> {
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
77 changes: 76 additions & 1 deletion packages/api-kit/src/types/safeTransactionServiceTypes.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -287,3 +288,77 @@ export type EIP712TypedData = {
types: TypedDataField
message: Record<string, unknown>
}

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<SafeOperationConfirmation>
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<SafeOperationResponse>
}

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
}
}
1 change: 1 addition & 0 deletions packages/api-kit/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const EMPTY_DATA = '0x'
3 changes: 3 additions & 0 deletions packages/api-kit/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { EMPTY_DATA } from './constants'

export const isEmptyData = (input: string) => !input || input === EMPTY_DATA
Loading

0 comments on commit 71f978e

Please sign in to comment.