Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api-kit): 4337 API functions #777

Merged
merged 25 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
10e6907
Implement functions to call 4337 api endpoints
tmjssz Apr 19, 2024
aed236b
e2e tests for addSafeOperation function
tmjssz Apr 19, 2024
f4ad53d
e2e tests for getSafeOperation function
tmjssz Apr 19, 2024
9c49f60
e2e tests for getSafeOperationsByAddress function
tmjssz Apr 19, 2024
5153baa
Endpoint tests for 4337 api functions
tmjssz Apr 19, 2024
ba4937e
set staging backend for SafeOperations tests
dasanra Apr 26, 2024
a9e0a66
remove unnecessary property from test setup
dasanra Apr 26, 2024
4640747
Use string to avoid precision errors in gas parameters
dasanra Apr 26, 2024
f0846b4
fix endpoint test
dasanra Apr 26, 2024
295b68b
Move shared types to `safe-core-sdk-types`
tmjssz Apr 29, 2024
2097ef0
Mock bundler client calls for addSafeOperation endpoint tests
tmjssz May 3, 2024
236d0fc
Use mock data in realistic format for unit test
tmjssz May 8, 2024
483d90b
Small fix to remove `eslint-disable-next-line` line
tmjssz May 8, 2024
83f494e
Assert length of SafeOperations in the getSafeOperation e2e test
tmjssz May 8, 2024
b02b85e
Rename `isEmptyHexData` util function to `isEmptyData`
tmjssz May 8, 2024
46fd0c4
Extend getSafeOperationsByAddress parameters
tmjssz May 8, 2024
56315ef
Refactor `addSafeOperation` function params to remove internal coupli…
tmjssz May 14, 2024
05054d3
Update packages/api-kit/.env.example
tmjssz May 14, 2024
cd124d8
Adapt SafeOperation response object types to latest API changes
tmjssz May 15, 2024
18d0c84
Merge branch 'development' of https://github.com/safe-global/safe-cor…
yagopv May 16, 2024
9a4ad4f
Update new tests to use the new getKits utils
yagopv May 16, 2024
29b6cd1
Fix import
yagopv May 16, 2024
8099ed4
Rename master-copies
yagopv May 16, 2024
6ac7ea9
Rename `SafeOperation` class to `EthSafeOperation`
tmjssz May 16, 2024
26142c1
chore: add deprecations re-exporting deprecated types
dasanra May 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/api-kit/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
MNEMONIC=
PK=
PK=
tmjssz marked this conversation as resolved.
Show resolved Hide resolved
PIMLICO_API_KEY=
108 changes: 108 additions & 0 deletions packages/api-kit/src/SafeApiKit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
AddMessageProps,
AddSafeDelegateProps,
AddSafeOperationProps,
AllTransactionsListResponse,
AllTransactionsOptions,
DeleteSafeDelegateProps,
GetSafeDelegateProps,
GetSafeOperationListResponse,
SafeSingletonResponse,
GetSafeMessageListProps,
ModulesResponse,
Expand All @@ -20,6 +22,7 @@
SafeMultisigTransactionEstimate,
SafeMultisigTransactionEstimateResponse,
SafeMultisigTransactionListResponse,
SafeOperationResponse,
SafeServiceInfoResponse,
SignatureResponse,
TokenInfoListResponse,
Expand All @@ -34,6 +37,7 @@
SafeMultisigTransactionResponse
} from '@safe-global/safe-core-sdk-types'
import { TRANSACTION_SERVICE_URLS } from './utils/config'
import { isEmptyHexData } from './utils'

export interface SafeApiKitConfig {
/** chainId - The chainId */
Expand Down Expand Up @@ -109,7 +113,7 @@
* @throws "Not Found"
* @throws "Ensure this field has at least 1 hexadecimal chars (not counting 0x)."
*/
async decodeData(data: string): Promise<any> {

Check warning on line 116 in packages/api-kit/src/SafeApiKit.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
if (data === '') {
throw new Error('Invalid data')
}
Expand Down Expand Up @@ -319,7 +323,7 @@
const totp = Math.floor(Date.now() / 1000 / 3600)
const data = delegate + totp
const signature = await signer.signMessage(data)
const body: any = {

Check warning on line 326 in packages/api-kit/src/SafeApiKit.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
safe: safeAddress ? this.#getEip3770Address(safeAddress).address : null,
delegate,
delegator,
Expand Down Expand Up @@ -715,6 +719,110 @@
}
})
}

/**
* Get the SafeOperations that were sent from a particular address.
* @param safeAddress - The Safe address to retrieve SafeOperations for
* @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: string): Promise<GetSafeOperationListResponse> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We seem to be ignoring the paging parameters (offset, limit etc) while the response does have the full result (count, next). Is this deliberate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was not on purpose 😅 added the missing params now

if (!safeAddress) {
throw new Error('Safe address must not be empty')
}
const { address } = this.#getEip3770Address(safeAddress)

return sendRequest({
url: `${this.#txServiceBaseUrl}/v1/safes/${address}/safe-operations/`,
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 "SafeOperation is not signed by the given signer {signerAddress}"
*/
async addSafeOperation({
moduleAddress: moduleAddressProp,
safeAddress: safeAddressProp,
safeOperation,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So... I dont understand the use case for this particular function but here is some food for thought. Right now, there is no internal coupling between this and the Safe4337 module which is better then if we were importing the type from there. There is, however, an implicit one. For someone to use this endpoint, they would have to either: use Safe4337Pack#createTransaction or implementing an interface themselves that accomplishes the same thing (which is a bit awkward). Are there situation where all these things could be supplied by an external library or people should ALWAYS use both modules together? Should we really use such complex object just for an api proxy and should the api-kit itself sign in this case? Perhaps the interface here is just really an UserOperation (which is clearly defined by the ERC) and the fields associated with packing signature (validUntil, validAfter and the authorizer's signature). That data field also almost look like something to represent the internal state of the object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the function in 56315ef to not expect a SafeOperation anymore, but a UserOperation object.

signer
}: 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}`)
}

const signerAddress = await signer.getAddress()
const signature = safeOperation.getSignature(signerAddress)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was looking at the server-side code and this field seems to be validated like this:

safe_owners = self._get_owners(safe_address)
parsed_signatures = SafeSignature.parse_signature(
    signature, safe_operation_hash, safe_operation_hash_preimage
)
owners_processed = set()
safe_signatures = []
for safe_signature in parsed_signatures:
    # do stuff

Please just check if those are not every owner's concatenated signature. I think the SafeOperation#encodedSignatures() or something.


if (!signature) {
throw new Error(`SafeOperation is not signed by the given signer ${signerAddress}`)
}

const { data } = safeOperation

return sendRequest({
url: `${this.#txServiceBaseUrl}/v1/safes/${safeAddress}/safe-operations/`,
method: HttpMethod.Post,
body: {
nonce: Number(data.nonce),
initCode: isEmptyHexData(data.initCode) ? null : data.initCode,
callData: data.callData,
callDataGasLimit: data.callGasLimit.toString(),
verificationGasLimit: data.verificationGasLimit.toString(),
preVerificationGas: data.preVerificationGas.toString(),
maxFeePerGas: data.maxFeePerGas.toString(),
maxPriorityFeePerGas: data.maxPriorityFeePerGas.toString(),
paymasterAndData: isEmptyHexData(data.paymasterAndData) ? null : data.paymasterAndData,
entryPoint: data.entryPoint,
validAfter: !data.validAfter ? null : data.validAfter,
validUntil: !data.validUntil ? null : data.validUntil,
signature: signature.data,
moduleAddress
}
})
}
}

export default SafeApiKit
53 changes: 53 additions & 0 deletions packages/api-kit/src/types/safeTransactionServiceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
SafeMultisigTransactionResponse,
SafeTransactionData
} from '@safe-global/safe-core-sdk-types'
import SafeOperation from '@safe-global/relay-kit/src/packs/safe-4337/SafeOperation'
dasanra marked this conversation as resolved.
Show resolved Hide resolved

export type SafeServiceInfoResponse = {
readonly name: string
Expand Down Expand Up @@ -287,3 +288,55 @@ 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 SafeOperationResponse = {
readonly created: string
readonly modified: string
readonly ethereumTxHash: string
readonly sender: string
readonly userOperationHash: string
readonly safeOperationHash: 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
readonly validAfter: string
readonly validUntil: string
readonly moduleAddress: string
readonly confirmations?: Array<SafeOperationConfirmation>
readonly preparedSignature?: string
}

export type GetSafeOperationListResponse = {
readonly count: number
readonly next?: string
readonly previous?: string
readonly results: Array<SafeOperationResponse>
}

export type AddSafeOperationProps = {
/** Address of the Safe4337Module contract */
moduleAddress: string
/** Address of the Safe to add a SafeOperation for */
safeAddress: string
/** Signed SafeOperation object to add */
safeOperation: SafeOperation
/** Signer instance */
signer: Signer
}
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 isEmptyHexData = (input: string) => !input || input === EMPTY_DATA
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move the !input outside of the function or rename it since empty hex implies a hex.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the function

171 changes: 171 additions & 0 deletions packages/api-kit/tests/e2e/addSafeOperation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
import dotenv from 'dotenv'
import { Wallet, ethers } from 'ethers'
import { EthAdapter } from '@safe-global/safe-core-sdk-types'
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 { getSafe4337ModuleDeployment } from '@safe-global/safe-modules-deployments'
import { EthersAdapter } from 'packages/protocol-kit'
import { getServiceClient } from '../utils/setupServiceClient'
import config from '../utils/config'

dotenv.config()

const { PIMLICO_API_KEY } = process.env

chai.use(chaiAsPromised)

const SIGNER_PK = '0x83a415ca62e11f5fa5567e98450d0f82ae19ff36ef876c10a8d448c788a53676'
const SAFE_ADDRESS = '0x60C4Ab82D06Fd7dFE9517e17736C2Dcc77443EF0' // 1/1 Safe (v1.4.1) with signer above as owner + 4337 module enabled
dasanra marked this conversation as resolved.
Show resolved Hide resolved
const PAYMASTER_TOKEN_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'
const PAYMASTER_ADDRESS = '0x0000000000325602a77416A16136FDafd04b299f'
const BUNDLER_URL = `https://api.pimlico.io/v1/sepolia/rpc?apikey=${PIMLICO_API_KEY}`
const TX_SERVICE_URL = 'https://safe-transaction-sepolia.staging.5afe.dev/api'

let safeApiKit: SafeApiKit
let ethAdapter: EthAdapter
let safe4337Pack: Safe4337Pack
let signer: Wallet
let moduleAddress: string

describe('addSafeOperation', () => {
const transferUSDC = {
to: PAYMASTER_TOKEN_ADDRESS,
data: generateTransferCallData(SAFE_ADDRESS, 100_000n),
value: '0',
operation: 0
}

before(async () => {
;({ safeApiKit, ethAdapter, signer } = await getServiceClient(SIGNER_PK, TX_SERVICE_URL))

const ethersAdapter = new EthersAdapter({
ethers,
signerOrProvider: signer
})

safe4337Pack = await Safe4337Pack.init({
options: { safeAddress: SAFE_ADDRESS },
ethersAdapter,
rpcUrl: config.JSON_RPC,
bundlerUrl: BUNDLER_URL,
paymasterOptions: {
paymasterTokenAddress: PAYMASTER_TOKEN_ADDRESS,
paymasterAddress: PAYMASTER_ADDRESS
}
})

const chainId = (await ethAdapter.getChainId()).toString()

moduleAddress = getSafe4337ModuleDeployment({
released: true,
version: '0.2.0',
network: chainId
})?.networkAddresses[chainId] as string
})

describe('should fail', () => {
it('if safeAddress is empty', async () => {
const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] })
const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation)

await chai
.expect(
safeApiKit.addSafeOperation({
moduleAddress,
safeAddress: '',
safeOperation: signedSafeOperation,
signer
})
)
.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)

await chai
.expect(
safeApiKit.addSafeOperation({
moduleAddress,
safeAddress: '0x123',
safeOperation: signedSafeOperation,
signer
})
)
.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)

await chai
.expect(
safeApiKit.addSafeOperation({
moduleAddress: '',
safeAddress: SAFE_ADDRESS,
safeOperation: signedSafeOperation,
signer
})
)
.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)

await chai
.expect(
safeApiKit.addSafeOperation({
moduleAddress: '0x234',
safeAddress: SAFE_ADDRESS,
safeOperation: signedSafeOperation,
signer
})
)
.to.be.rejectedWith('Invalid module address 0x234')
})

it('if the SafeOperation is not signed', async () => {
const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] })

await chai
.expect(
safeApiKit.addSafeOperation({
moduleAddress,
safeAddress: SAFE_ADDRESS,
safeOperation,
signer
})
)
.to.be.rejectedWith(
'SafeOperation is not signed by the given signer 0x56e2C102c664De6DfD7315d12c0178b61D16F171'
)
})
})

it('should add a new SafeOperation', async () => {
const safeOperation = await safe4337Pack.createTransaction({ transactions: [transferUSDC] })
const signedSafeOperation = await safe4337Pack.signSafeOperation(safeOperation)

const safeOperationsBefore = await safeApiKit.getSafeOperationsByAddress(SAFE_ADDRESS)
const initialNumSafeOperations = safeOperationsBefore.results.length

await chai.expect(
safeApiKit.addSafeOperation({
moduleAddress,
safeAddress: SAFE_ADDRESS,
safeOperation: signedSafeOperation,
signer
})
).to.be.fulfilled

const safeOperationsAfter = await safeApiKit.getSafeOperationsByAddress(SAFE_ADDRESS)
chai.expect(safeOperationsAfter.results.length).to.equal(initialNumSafeOperations + 1)
})
})
Loading
Loading