diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index 3b9dd9859..5bfaf4464 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -84,6 +84,7 @@ import { asHash, asHex } from './utils/types' import { Hash, Hex } from 'viem' import getPasskeyOwnerAddress from './utils/passkeys/getPasskeyOwnerAddress' import createPasskeyDeploymentTransaction from './utils/passkeys/createPasskeyDeploymentTransaction' +import formatTrackId from './utils/on-chain-tracking/formatTrackId' const EQ_OR_GT_1_4_1 = '>=1.4.1' const EQ_OR_GT_1_3_0 = '>=1.3.0' @@ -100,6 +101,9 @@ class Safe { #MAGIC_VALUE = '0x1626ba7e' #MAGIC_VALUE_BYTES = '0x20c13b0b' + // On-chain Analitics + #trackId: string = '' + /** * Creates an instance of the Safe Core SDK. * @param config - Ethers Safe configuration @@ -124,7 +128,11 @@ class Safe { * @throws "MultiSendCallOnly contract is not deployed on the current network" */ async #initializeProtocolKit(config: SafeConfig) { - const { provider, signer, isL1SafeSingleton, contractNetworks } = config + const { provider, signer, isL1SafeSingleton, contractNetworks, trackId } = config + + if (trackId) { + this.#trackId = formatTrackId(trackId) + } this.#safeProvider = await SafeProvider.init({ provider, @@ -252,6 +260,7 @@ class Safe { safeProvider: this.#safeProvider, chainId, customContracts: this.#contractManager.contractNetworks?.[chainId.toString()], + trackId: this.#trackId, ...this.#predictedSafe }) } @@ -284,6 +293,7 @@ class Safe { chainId, isL1SafeSingleton: this.#contractManager.isL1SafeSingleton, customContracts: this.#contractManager.contractNetworks?.[chainId.toString()], + trackId: this.#trackId, ...this.#predictedSafe }) } @@ -534,7 +544,8 @@ class Safe { predictedSafe: this.#predictedSafe, provider: this.#safeProvider.provider, tx: newTransaction, - contractNetworks: this.#contractManager.contractNetworks + contractNetworks: this.#contractManager.contractNetworks, + trackId: this.#trackId }) ) } @@ -547,7 +558,8 @@ class Safe { safeContract: this.#contractManager.safeContract, provider: this.#safeProvider.provider, tx: newTransaction, - contractNetworks: this.#contractManager.contractNetworks + contractNetworks: this.#contractManager.contractNetworks, + trackId: this.#trackId }) ) } @@ -1545,7 +1557,8 @@ class Safe { safeContract: safeSingletonContract, safeAccountConfig: safeAccountConfig, customContracts, - deploymentType + deploymentType, + trackId: this.#trackId }) const safeDeployTransactionData = { @@ -1698,6 +1711,10 @@ class Safe { }): ContractInfo | undefined => { return getContractInfo(contractAddress) } + + getTrackId(): string { + return this.#trackId + } } export default Safe diff --git a/packages/protocol-kit/src/contracts/utils.ts b/packages/protocol-kit/src/contracts/utils.ts index a8f0f268a..db95bd27c 100644 --- a/packages/protocol-kit/src/contracts/utils.ts +++ b/packages/protocol-kit/src/contracts/utils.ts @@ -75,6 +75,7 @@ export interface PredictSafeAddressProps { safeDeploymentConfig?: SafeDeploymentConfig isL1SafeSingleton?: boolean customContracts?: ContractNetworkConfig + trackId?: string } export interface encodeSetupCallDataProps { @@ -84,6 +85,7 @@ export interface encodeSetupCallDataProps { customContracts?: ContractNetworkConfig customSafeVersion?: SafeVersion deploymentType?: DeploymentType + trackId: string } export function encodeCreateProxyWithNonce( @@ -109,7 +111,8 @@ export async function encodeSetupCallData({ safeContract, customContracts, customSafeVersion, - deploymentType + deploymentType, + trackId }: encodeSetupCallDataProps): Promise { const { owners, @@ -119,7 +122,7 @@ export async function encodeSetupCallData({ fallbackHandler, paymentToken = ZERO_ADDRESS, payment = 0, - paymentReceiver = ZERO_ADDRESS + paymentReceiver = trackId || ZERO_ADDRESS } = safeAccountConfig const safeVersion = customSafeVersion || safeContract.safeVersion @@ -255,7 +258,8 @@ export async function getPredictedSafeAddressInitCode({ safeAccountConfig, safeDeploymentConfig = {}, isL1SafeSingleton, - customContracts + customContracts, + trackId = '' }: PredictSafeAddressProps): Promise { validateSafeAccountConfig(safeAccountConfig) validateSafeDeploymentConfig(safeDeploymentConfig) @@ -289,7 +293,8 @@ export async function getPredictedSafeAddressInitCode({ safeContract, customContracts, customSafeVersion: safeVersion, // it is more efficient if we provide the safeVersion manually - deploymentType + deploymentType, + trackId }) const encodedNonce = safeProvider.encodeParameters('uint256', [saltNonce]) @@ -315,7 +320,8 @@ export async function predictSafeAddress({ safeAccountConfig, safeDeploymentConfig = {}, isL1SafeSingleton, - customContracts + customContracts, + trackId = '' }: PredictSafeAddressProps): Promise { validateSafeAccountConfig(safeAccountConfig) validateSafeDeploymentConfig(safeDeploymentConfig) @@ -357,7 +363,8 @@ export async function predictSafeAddress({ safeContract, customContracts, customSafeVersion: safeVersion, // it is more efficient if we provide the safeVersion manuall - deploymentType + deploymentType, + trackId }) const initializerHash = keccak256(asHex(initializer)) diff --git a/packages/protocol-kit/src/types/safeConfig.ts b/packages/protocol-kit/src/types/safeConfig.ts index 11d606ab9..9fa1b16ac 100644 --- a/packages/protocol-kit/src/types/safeConfig.ts +++ b/packages/protocol-kit/src/types/safeConfig.ts @@ -48,6 +48,8 @@ export type SafeConfigProps = { isL1SafeSingleton?: boolean /** contractNetworks - Contract network configuration */ contractNetworks?: ContractNetworksConfig + // on-chain analitics + trackId?: string } export type SafeConfigWithSafeAddress = SafeConfigProps & SafeConfigWithSafeAddressProps @@ -75,6 +77,8 @@ type ConnectSafeConfigProps = { isL1SafeSingleton?: boolean /** contractNetworks - Contract network configuration */ contractNetworks?: ContractNetworksConfig + // on-chain analitics + trackId?: string } export type ConnectSafeConfigWithSafeAddress = ConnectSafeConfigProps & diff --git a/packages/protocol-kit/src/types/transactions.ts b/packages/protocol-kit/src/types/transactions.ts index 9a4124d73..368716a70 100644 --- a/packages/protocol-kit/src/types/transactions.ts +++ b/packages/protocol-kit/src/types/transactions.ts @@ -37,6 +37,7 @@ type StandardizeSafeTransactionData = { tx: SafeTransactionDataPartial /** contractNetworks - Contract network configuration */ contractNetworks?: ContractNetworksConfig + trackId: string } export type StandardizeSafeTxDataWithSafeContract = StandardizeSafeTransactionData & diff --git a/packages/protocol-kit/src/utils/on-chain-tracking/formatTrackId.ts b/packages/protocol-kit/src/utils/on-chain-tracking/formatTrackId.ts new file mode 100644 index 000000000..24af48e4e --- /dev/null +++ b/packages/protocol-kit/src/utils/on-chain-tracking/formatTrackId.ts @@ -0,0 +1,14 @@ +import { keccak256, toHex } from 'viem' + +/** + * Formats a trackId to convert it into an Ethereum address. This address can be used in + * fields like `paymentReceiver` and `refundReceiver` to track Safe transactions and deployments. + * + * @param {string} trackId - The identifier provided by the user. + * @returns {`0x${string}`} - The Ethereum address derived from the identifier. + */ +function formatTrackId(trackId: string): `0x${string}` { + return `0x${keccak256(toHex(trackId)).substring(2, 42)}` // only first 40 chars from the hash +} + +export default formatTrackId diff --git a/packages/protocol-kit/src/utils/transactions/utils.ts b/packages/protocol-kit/src/utils/transactions/utils.ts index 05767898f..465a096e4 100644 --- a/packages/protocol-kit/src/utils/transactions/utils.ts +++ b/packages/protocol-kit/src/utils/transactions/utils.ts @@ -61,7 +61,8 @@ export async function standardizeSafeTransactionData({ predictedSafe, provider, tx, - contractNetworks + contractNetworks, + trackId }: StandardizeSafeTransactionDataProps): Promise { const standardizedTxs = { to: tx.to, @@ -71,7 +72,7 @@ export async function standardizeSafeTransactionData({ baseGas: tx.baseGas ?? '0', gasPrice: tx.gasPrice ?? '0', gasToken: tx.gasToken || ZERO_ADDRESS, - refundReceiver: tx.refundReceiver || ZERO_ADDRESS, + refundReceiver: tx.refundReceiver || trackId || ZERO_ADDRESS, nonce: tx.nonce ?? (safeContract ? Number(await safeContract.getNonce()) : 0) } diff --git a/packages/protocol-kit/tests/e2e/onChainTrackId.test.ts b/packages/protocol-kit/tests/e2e/onChainTrackId.test.ts new file mode 100644 index 000000000..162deec48 --- /dev/null +++ b/packages/protocol-kit/tests/e2e/onChainTrackId.test.ts @@ -0,0 +1,253 @@ +import { setupTests, safeVersionDeployed } from '@safe-global/testing-kit' +import semverSatisfies from 'semver/functions/satisfies' +import Safe, { + getSafeContract, + getSafeProxyFactoryContract, + PredictedSafeProps, + SafeAccountConfig, + SafeProvider +} from '@safe-global/protocol-kit/index' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' + +import { getEip1193Provider } from './utils/setupProvider' +import { decodeFunctionData } from 'viem' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/utils/constants' + +chai.use(chaiAsPromised) + +describe('On-chain analytics', () => { + const provider = getEip1193Provider() + + describe('getTrackId method', () => { + it('should return the correctly formatted track id when provided', async () => { + const trackId = 'test-track-id' + const { safe, contractNetworks } = await setupTests() + const safeAddress = safe.address + + const protocolKit = await Safe.init({ + provider, + safeAddress, + contractNetworks, + trackId + }) + + const formattedTrackId = '0x7ba67a7e86c9fad9f51790e7e60307f882b9c492' + + chai.expect(formattedTrackId).to.equals(protocolKit.getTrackId()) + }) + + it('should return an empty string when no track id is provided', async () => { + const { safe, contractNetworks } = await setupTests() + const safeAddress = safe.address + + const protocolKit = await Safe.init({ + provider, + safeAddress, + contractNetworks + }) + + chai.expect(protocolKit.getTrackId()).to.empty + }) + }) + + describe('Tracking Safe Deployment on Chain via paymentReceiver field in setup method', () => { + it('should include the formatted trackId in the paymentReceiver field during Safe deployment', async () => { + const trackId = 'test-track-id' + + const { chainId, accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const owners = [account1.address, account2.address] + const threshold = 1 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const predictedSafe: PredictedSafeProps = { + safeAccountConfig, + safeDeploymentConfig: { + safeVersion: safeVersionDeployed + } + } + + const protocolKit = await Safe.init({ + provider, + predictedSafe, + contractNetworks, + trackId + }) + + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + + chai.expect(deploymentTransaction.data).to.include(protocolKit.getTrackId().replace('0x', '')) + + const customContracts = contractNetworks[chainId.toString()] + + const safeProvider = new SafeProvider({ provider }) + + const proxyFactoryContract = await getSafeProxyFactoryContract({ + safeProvider, + safeVersion: safeVersionDeployed, + customContracts + }) + + proxyFactoryContract.contractAbi + + const decodedDataDeployment = decodeFunctionData({ + abi: proxyFactoryContract.contractAbi, + data: deploymentTransaction.data as `0x${string}` + }) + + const initializer = decodedDataDeployment.args[1] + + const safeContract = await getSafeContract({ + safeProvider, + safeVersion: safeVersionDeployed, + customContracts + }) + + if (semverSatisfies(safeVersionDeployed, '<=1.0.0')) { + const decodedDataSetup = decodeFunctionData({ + abi: safeContract.contractAbi, + data: initializer as `0x${string}` + }) + + const paymentReceiver = (decodedDataSetup.args[6] as string)?.toLowerCase() + + chai.expect(paymentReceiver).to.equals(protocolKit.getTrackId()) + } else { + const decodedDataSetup = decodeFunctionData({ + abi: safeContract.contractAbi, + data: initializer as `0x${string}` + }) + + const paymentReceiver = decodedDataSetup.args[7]?.toLowerCase() + + chai.expect(paymentReceiver).to.equals(protocolKit.getTrackId()) + } + }) + + it('should set the paymentReceiver to the zero address when no trackId is provided during Safe deployment', async () => { + const { chainId, accounts, contractNetworks } = await setupTests() + const [account1, account2] = accounts + const owners = [account1.address, account2.address] + const threshold = 1 + const safeAccountConfig: SafeAccountConfig = { owners, threshold } + const predictedSafe: PredictedSafeProps = { + safeAccountConfig, + safeDeploymentConfig: { + safeVersion: safeVersionDeployed + } + } + + const protocolKit = await Safe.init({ + provider, + predictedSafe, + contractNetworks + }) + + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + + chai.expect(deploymentTransaction.data).to.include(protocolKit.getTrackId().replace('0x', '')) + + const customContracts = contractNetworks[chainId.toString()] + + const safeProvider = new SafeProvider({ provider }) + + const proxyFactoryContract = await getSafeProxyFactoryContract({ + safeProvider, + safeVersion: safeVersionDeployed, + customContracts + }) + + proxyFactoryContract.contractAbi + + const decodedDataDeployment = decodeFunctionData({ + abi: proxyFactoryContract.contractAbi, + data: deploymentTransaction.data as `0x${string}` + }) + + const initializer = decodedDataDeployment.args[1] + + const safeContract = await getSafeContract({ + safeProvider, + safeVersion: safeVersionDeployed, + customContracts + }) + + if (semverSatisfies(safeVersionDeployed, '<=1.0.0')) { + const decodedDataSetup = decodeFunctionData({ + abi: safeContract.contractAbi, + data: initializer as `0x${string}` + }) + + const paymentReceiver = (decodedDataSetup.args[6] as string)?.toLowerCase() + + chai.expect(paymentReceiver).to.equals(ZERO_ADDRESS) + } else { + const decodedDataSetup = decodeFunctionData({ + abi: safeContract.contractAbi, + data: initializer as `0x${string}` + }) + + const paymentReceiver = decodedDataSetup.args[7]?.toLowerCase() + + chai.expect(paymentReceiver).to.equals(ZERO_ADDRESS) + } + }) + }) + + describe('Tracking Safe transactions on Chain via refundReceiver field in execTransaction method', () => { + it('should include the formatted trackId in the refundReceiver field during the transaction execution', async () => { + const trackId = 'test-track-id' + const { safe, contractNetworks } = await setupTests() + const safeAddress = safe.address + + const protocolKit = await Safe.init({ + provider, + safeAddress, + contractNetworks, + trackId + }) + + const testTransaction = { + to: safeAddress, + value: '0', + data: '0x' + } + + const safeTransaction = await protocolKit.createTransaction({ + transactions: [testTransaction] + }) + + const isValidTransaction = await protocolKit.isValidTransaction(safeTransaction) + chai.expect(isValidTransaction).to.be.eq(true) + + const formattedTrackId = protocolKit.getTrackId() + chai.expect(safeTransaction.data.refundReceiver).to.equals(formattedTrackId) + }) + + it('should set the refundReceiver to the zero address when no trackId is provided', async () => { + const { safe, contractNetworks } = await setupTests() + const safeAddress = safe.address + + const protocolKit = await Safe.init({ + provider, + safeAddress, + contractNetworks + }) + + const testTransaction = { + to: safeAddress, + value: '0', + data: '0x' + } + + const safeTransaction = await protocolKit.createTransaction({ + transactions: [testTransaction] + }) + + const isValidTransaction = await protocolKit.isValidTransaction(safeTransaction) + chai.expect(isValidTransaction).to.be.eq(true) + + chai.expect(safeTransaction.data.refundReceiver).to.equals(ZERO_ADDRESS) + }) + }) +}) diff --git a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts index aa045d232..ea2c768e8 100644 --- a/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts +++ b/packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts @@ -124,7 +124,8 @@ export class Safe4337Pack extends RelayKitBasePack<{ * @return {Promise} The Promise object that will be resolved into an instance of Safe4337Pack. */ static async init(initOptions: Safe4337InitOptions): Promise { - const { provider, signer, options, bundlerUrl, customContracts, paymasterOptions } = initOptions + const { provider, signer, options, bundlerUrl, customContracts, paymasterOptions, trackId } = + initOptions let protocolKit: Safe const bundlerClient = getEip4337BundlerProvider(bundlerUrl) const chainId = await bundlerClient.request({ method: RPC_4337_CALLS.CHAIN_ID }) @@ -172,7 +173,8 @@ export class Safe4337Pack extends RelayKitBasePack<{ protocolKit = await Safe.init({ provider, signer, - safeAddress: options.safeAddress + safeAddress: options.safeAddress, + trackId }) const safeVersion = protocolKit.getContractVersion() @@ -329,7 +331,8 @@ export class Safe4337Pack extends RelayKitBasePack<{ payment: 0, paymentReceiver: zeroAddress } - } + }, + trackId }) } diff --git a/packages/relay-kit/src/packs/safe-4337/types.ts b/packages/relay-kit/src/packs/safe-4337/types.ts index a64bda523..a6f741216 100644 --- a/packages/relay-kit/src/packs/safe-4337/types.ts +++ b/packages/relay-kit/src/packs/safe-4337/types.ts @@ -50,6 +50,7 @@ export type Safe4337InitOptions = { } options: ExistingSafeOptions | PredictedSafeOptions paymasterOptions?: PaymasterOptions + trackId?: string } export type Safe4337Options = { diff --git a/playground/protocol-kit/deploy-safe.ts b/playground/protocol-kit/deploy-safe.ts index eadbf568b..58af6aa09 100644 --- a/playground/protocol-kit/deploy-safe.ts +++ b/playground/protocol-kit/deploy-safe.ts @@ -18,6 +18,7 @@ interface Config { SALT_NONCE: string SAFE_VERSION: string } + trackId: string } const config: Config = { @@ -27,8 +28,9 @@ const config: Config = { OWNERS: ['OWNER_ADDRESS'], THRESHOLD: 1, // SALT_NONCE: '150000', - SAFE_VERSION: '1.3.0' - } + SAFE_VERSION: '1.0.0' + }, + trackId: '' // On chain analitic } async function main() { @@ -53,9 +55,15 @@ async function main() { saltNonce, safeVersion } - } + }, + trackId: config.trackId }) + if (config.trackId) { + console.log('trackId: ', config.trackId) + console.log('on-chain trackId: ', protocolKit.getTrackId()) + } + // The Account Abstraction feature is only available for Safes version 1.3.0 and above. if (semverSatisfies(safeVersion, '>=1.3.0')) { // check if its deployed @@ -96,7 +104,7 @@ async function main() { console.log('safeAddress:', safeAddress) // now you can use the Safe address in the instance of the protocol-kit - protocolKit.connect({ safeAddress }) + await protocolKit.connect({ safeAddress }) console.log('is Safe deployed:', await protocolKit.isSafeDeployed()) console.log('Safe Address:', await protocolKit.getAddress())