diff --git a/package-lock.json b/package-lock.json index 5bbd69f56..0a436e21d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@prisma/client": "^5.7.1", "axios": "^1.6.3", "bull": "^4.12.0", + "caip": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "clsx": "^1.2.1", @@ -9927,6 +9928,26 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, + "node_modules/abitype": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.10.0.tgz", + "integrity": "sha512-QvMHEUzgI9nPj9TWtUGnS2scas80/qaL5PBxGdwWhhvzqXfOph+IEiiiWrzuisu3U3JgDQVruW9oLbJoQ3oZ3A==", + "funding": { + "url": "https://github.com/sponsors/wagmi-dev" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -11071,6 +11092,11 @@ "node": ">=14.16" } }, + "node_modules/caip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/caip/-/caip-1.1.0.tgz", + "integrity": "sha512-yOO3Fu4ygyKYAdznuoaqschMKIZzcdgyMpBNtrIfrUhnOeaOWG+dh0c13wcOS6B/46IGGbncoyzJlio79jU7rw==" + }, "node_modules/call-bind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", diff --git a/package.json b/package.json index b5404ac11..023a3dada 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@prisma/client": "^5.7.1", "axios": "^1.6.3", "bull": "^4.12.0", + "caip": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "clsx": "^1.2.1", diff --git a/packages/transaction-request-intent/src/index.ts b/packages/transaction-request-intent/src/index.ts index c44835810..6f245cc9e 100644 --- a/packages/transaction-request-intent/src/index.ts +++ b/packages/transaction-request-intent/src/index.ts @@ -1 +1,2 @@ -export * from './lib/transaction-request-intent' +export * from './lib/decoders' +export * from './lib/intent' diff --git a/packages/transaction-request-intent/src/lib/__test__/unit/decoders.spec.ts b/packages/transaction-request-intent/src/lib/__test__/unit/decoders.spec.ts new file mode 100644 index 000000000..97ed89e9c --- /dev/null +++ b/packages/transaction-request-intent/src/lib/__test__/unit/decoders.spec.ts @@ -0,0 +1,32 @@ +import { AssetTypeEnum, Intents } from '../../../utils/domain'; +import { decodeIntent } from '../../../lib/decoders'; +import { IntentRequest } from '../../../shared/types'; +import { Erc20Methods } from '../../../utils/standard-functions/methodId'; + +jest.mock('viem', () => ({ + decodeAbiParameters: jest.fn() + .mockReturnValueOnce(['0x031d8C0cA142921c459bCB28104c0FF37928F9eD', BigInt('428406414311469998210669')]) +})); + +describe('decodeIntent', () => { + it('decodes ERC20 intent correctly', () => { + const erc20Request: IntentRequest = { + methodId: Erc20Methods.TRANSFER, + assetType: AssetTypeEnum.ERC20, + type: Intents.TRANSFER_ERC20, + validatedFields: { + data: '0xa9059cbb000000000000000000000000031d8c0ca142921c459bcb28104c0ff37928f9ed000000000000000000000000000000000000000000005ab7f55035d1e7b4fe6d', + to: '0x000000000000000000000000000000000000000001', + chainId: '1', + }, + }; + + const result = decodeIntent(erc20Request); + + expect(result).toEqual({ + type: Intents.TRANSFER_ERC20, + amount: '428406414311469998210669', + token: 'eip155:1:0x000000000000000000000000000000000000000001', + }); + }); +}); diff --git a/packages/transaction-request-intent/src/lib/__test__/unit/param-extractors.spec.ts b/packages/transaction-request-intent/src/lib/__test__/unit/param-extractors.spec.ts new file mode 100644 index 000000000..9027cee3f --- /dev/null +++ b/packages/transaction-request-intent/src/lib/__test__/unit/param-extractors.spec.ts @@ -0,0 +1,20 @@ + +import { extractErc20TransferAmount } from '../../../utils/standard-functions/param-extractors'; + +jest.mock('viem', () => ({ + decodeAbiParameters: jest.fn() + .mockResolvedValueOnce([]) + .mockReturnValueOnce(['0x031d8C0cA142921c459bCB28104c0FF37928F9eD', BigInt('428406414311469998210669')]) +})); + +const invalidData = '0xInvalidData'; +const validData = '0xa9059cbb000000000000000000000000031d8c0ca142921c459bcb28104c0ff37928f9ed000000000000000000000000000000000000000000005ab7f55035d1e7b4fe6d'; +describe('extractErc20TransferAmount', () => { + it('throws on incorrect data', () => { + expect(() => extractErc20TransferAmount(invalidData)).toThrow('Malformed transaction request'); + }); + + it('successfully extract amount on valid data', () => { + expect(extractErc20TransferAmount(validData)).toEqual('428406414311469998210669'); + }); +}); diff --git a/packages/transaction-request-intent/src/lib/__test__/unit/transaction-request-intent.spec.ts b/packages/transaction-request-intent/src/lib/__test__/unit/transaction-request-intent.spec.ts deleted file mode 100644 index fdd1349b6..000000000 --- a/packages/transaction-request-intent/src/lib/__test__/unit/transaction-request-intent.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { transactionRequestIntent } from '@narval/transaction-request-intent' - -describe('transactionRequestIntent', () => { - it('should work', () => { - expect(transactionRequestIntent()).toEqual('transaction-request-intent') - }) -}) diff --git a/packages/transaction-request-intent/src/lib/decoders.ts b/packages/transaction-request-intent/src/lib/decoders.ts new file mode 100644 index 000000000..3987bff15 --- /dev/null +++ b/packages/transaction-request-intent/src/lib/decoders.ts @@ -0,0 +1,83 @@ +import { Intent, TransferErc20, TransferErc721 } from '../../src/utils/intent.types'; +import { encodeEoaAccountId, encodeEoaAssetId } from '../../src/utils/caip'; +import { AssetTypeEnum, EipStandardEnum, Intents } from '../../src/utils/domain'; +import { extractErc20Amount, extractErc721AssetId } from '../../src/utils/standard-functions/param-extractors'; +import { IntentRequest } from '../../src/shared/types'; + +export const decodeErc721 = ({ + data, + methodId, + chainId, + assetType, + to, +}: { + data: `0x${string}`, + methodId: string, + chainId: number, + assetType: AssetTypeEnum, + to: `0x${string}`, +}) => { + const intent: TransferErc721 = { + type: Intents.TRANSFER_ERC721, + nftId: encodeEoaAssetId({ + eipStandard: EipStandardEnum.EIP155, + assetType, + chainId, + evmAccountAddress: to, + tokenId: extractErc721AssetId(data, methodId), + }), + nftContract: encodeEoaAccountId({ + chainId, + evmAccountAddress: to, + }), + } + return intent; +} + +export const decodeErc20 = ({ + to, + data, + chainId, + methodId, +}: { + to: `0x${string}`, + data: `0x${string}`, + chainId: number, + methodId: string, +}): TransferErc20 => { + + const intent: TransferErc20 = { + type: Intents.TRANSFER_ERC20, + amount: extractErc20Amount(data, methodId), + token: encodeEoaAccountId({ + chainId, + evmAccountAddress: to, + }), + } + + return intent; +}; + +export const decodeIntent = (request: IntentRequest): Intent => { + const { methodId, type } = request; + + switch (type) { + case Intents.TRANSFER_ERC20: + return decodeErc20({ + to: request.validatedFields.to, + data: request.validatedFields.data, + chainId: +request.validatedFields.chainId, + methodId, + }); + case Intents.TRANSFER_ERC721: + return decodeErc721({ + assetType: request.assetType, + to: request.validatedFields.to, + data: request.validatedFields.data, + chainId: +request.validatedFields.chainId, + methodId, + }); + default: + throw new Error('Unsupported intent'); + } +} diff --git a/packages/transaction-request-intent/src/lib/intent.ts b/packages/transaction-request-intent/src/lib/intent.ts new file mode 100644 index 000000000..eca9fd14b --- /dev/null +++ b/packages/transaction-request-intent/src/lib/intent.ts @@ -0,0 +1,72 @@ +import { AssetTypeEnum, Intents, NATIVE_TRANSFER } from '../../src/utils/domain'; +import { TransactionRequest } from '../../src/utils/transaction.type'; +import { Intent } from '../../src/utils/intent.types'; +import { decodeIntent } from './decoders'; +import { Erc20TransferAbi, Erc721TransferAbi } from '../../src/utils/standard-functions/abis'; +import { IntentRequest } from '../../src/shared/types'; + +const methodIdToAssetTypeMap: { [key: string]: AssetTypeEnum } = { + ...Object.entries(Erc20TransferAbi).reduce((acc, [key]) => ({ ...acc, [key]: AssetTypeEnum.ERC20 }), {}), + ...Object.entries(Erc721TransferAbi).reduce((acc, [key]) => ({ ...acc, [key]: AssetTypeEnum.ERC721 }), {}), + [NATIVE_TRANSFER]: AssetTypeEnum.NATIVE, +}; + +export const determineType = (methodId: string): AssetTypeEnum => { + return methodIdToAssetTypeMap[methodId] || AssetTypeEnum.UNKNOWN; +}; + +export const getIntentType = (assetType: AssetTypeEnum): Intents => { + switch (assetType) { + case AssetTypeEnum.ERC20: + return Intents.TRANSFER_ERC20; + case AssetTypeEnum.ERC721: + return Intents.TRANSFER_ERC721; + case AssetTypeEnum.ERC1155: + return Intents.TRANSFER_ERC1155; + case AssetTypeEnum.NATIVE: + return Intents.TRANSFER_NATIVE; + default: + return Intents.CALL_CONTRACT; + } +} + +export const getMethodId = (data?: string): string => data ? data.slice(0, 10): NATIVE_TRANSFER; + +export const validateErc20Intent = (txRequest: TransactionRequest) => { + const { data, to, chainId } = txRequest; + if (!data || !to || !chainId) { + throw new Error('Malformed Erc20 transaction request'); + } + return { data, to, chainId } +} + +export const validateIntent = (txRequest: TransactionRequest): IntentRequest => { + const { from, value, data, chainId } = txRequest; + if (!from || !chainId) { + throw new Error('Malformed transaction request: missing from or chainId'); + } + if (!value && !data) { + throw new Error('Malformed transaction request: missing value and data'); + } + + const methodId = getMethodId(data); + const assetType = determineType(methodId); + const type = getIntentType(assetType); + + switch (type) { + case Intents.TRANSFER_ERC20: + return { + type, + assetType, + methodId, + validatedFields: validateErc20Intent(txRequest), + } + default: + throw new Error('Unsupported intent'); + } +}; + +export const decodeTransaction = (txRequest: TransactionRequest): Intent => { + const request = validateIntent(txRequest); + return decodeIntent(request); +}; diff --git a/packages/transaction-request-intent/src/lib/transaction-request-intent.ts b/packages/transaction-request-intent/src/lib/transaction-request-intent.ts deleted file mode 100644 index 0f0d454d5..000000000 --- a/packages/transaction-request-intent/src/lib/transaction-request-intent.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function transactionRequestIntent(): string { - return 'transaction-request-intent' -} diff --git a/packages/transaction-request-intent/src/shared/types.ts b/packages/transaction-request-intent/src/shared/types.ts new file mode 100644 index 000000000..f7262493f --- /dev/null +++ b/packages/transaction-request-intent/src/shared/types.ts @@ -0,0 +1,21 @@ +import { AssetTypeEnum, Intents } from '../utils/domain'; + +export type TransferIntent = { + data: `0x${string}`; + to: `0x${string}`; + chainId: string; +} + +export type IntentRequest = { + methodId: string; + assetType: AssetTypeEnum; +} & ( + | { + type: Intents.TRANSFER_ERC20; + validatedFields: TransferIntent; + } + | { + type: Intents.TRANSFER_ERC721; + validatedFields: TransferIntent; + } +) \ No newline at end of file diff --git a/packages/transaction-request-intent/src/utils/caip.ts b/packages/transaction-request-intent/src/utils/caip.ts new file mode 100644 index 000000000..2400743f0 --- /dev/null +++ b/packages/transaction-request-intent/src/utils/caip.ts @@ -0,0 +1,49 @@ +import { Address } from 'viem'; +import { AssetTypeEnum, EipStandardEnum } from './domain'; +import { SetOptional } from 'type-fest'; +import { AccountId, AssetId } from 'caip'; + +export type Caip10 = string & { readonly brand: unique symbol }; + +export type Caip19 = string & { readonly brand: unique symbol }; + +export type Caip10Standards = { + chainId: number | 'eoa'; + evmAccountAddress: Address; + eipStandard: EipStandardEnum; +}; + +export type Caip19Standards = Caip10Standards & { + tokenId: string; + assetType: AssetTypeEnum; +}; + +export const encodeEoaAccountId = ({ + chainId, + evmAccountAddress, + eipStandard = EipStandardEnum.EIP155, +}: SetOptional): Caip10 => + new AccountId({ + chainId: { namespace: eipStandard, reference: chainId.toString() }, + address: evmAccountAddress.toLowerCase(), + }) + .toString() + .toLowerCase() as Caip10; + +export const encodeEoaAssetId = ({ + chainId, + evmAccountAddress, + eipStandard = EipStandardEnum.EIP155, + tokenId, + assetType, +}: Caip19Standards): Caip19 => + new AssetId({ + chainId: { namespace: eipStandard, reference: chainId.toString() }, + assetName: { + namespace: assetType, + reference: evmAccountAddress, + }, + tokenId, + }) + .toString() + .toLowerCase() as Caip19; diff --git a/packages/transaction-request-intent/src/utils/domain.ts b/packages/transaction-request-intent/src/utils/domain.ts new file mode 100644 index 000000000..f68d01905 --- /dev/null +++ b/packages/transaction-request-intent/src/utils/domain.ts @@ -0,0 +1,35 @@ +export enum BlockchainActions { + SIGN_TRANSACTION = 'signTransaction', + SIGN_RAW = 'signRaw', + SIGN_MESSAGE = 'signMessage', + SIGN_TYPED_DATA = 'signTypedData', +} + +export enum PolicyManagementActions { + SET_POLICY_RULES = 'setPolicyRules', +} + +export type Actions = BlockchainActions | PolicyManagementActions; + +export enum Intents { + TRANSFER_NATIVE = 'transferNative', + TRANSFER_ERC20 = 'transferErc20', + TRANSFER_ERC721 = 'transferErc721', + TRANSFER_ERC1155 = 'transferErc1155', + CALL_CONTRACT = 'callContract', +} + +export const NATIVE_TRANSFER = 'nativeTransfer'; + +// TODO: Move below in a folder shared with other apps, these should be shared within the whole project +export enum AssetTypeEnum { + ERC1155 = 'erc1155', + ERC20 = 'erc20', + ERC721 = 'erc721', + NATIVE = 'native', + UNKNOWN = 'unknown', +} + +export enum EipStandardEnum { + EIP155 = 'eip155', +} diff --git a/packages/transaction-request-intent/src/utils/intent.types.ts b/packages/transaction-request-intent/src/utils/intent.types.ts new file mode 100644 index 000000000..0206bb9f5 --- /dev/null +++ b/packages/transaction-request-intent/src/utils/intent.types.ts @@ -0,0 +1,42 @@ +import { Intents } from './domain'; +import { Caip10, Caip19 } from './caip'; + +export type ContractFunction = { + hexSignature: string; +}; + +export type TransferNative = { + type: Intents.TRANSFER_NATIVE; + amount: string; + native: Caip10; +}; + +export type TransferErc20 = { + type: Intents.TRANSFER_ERC20; + amount: string; + token: Caip10; +}; + +export type TransferErc721 = { + type: Intents.TRANSFER_ERC721; + nftId: Caip19; + nftContract: Caip10; +}; + +// --> Handle rules for ERC1155 like +// - allow only non-fungible transfers up to x +// - allow only fungible transfers + +export type TransferErc1155 = { + type: Intents.TRANSFER_ERC1155; + nftContract: Caip10; + fungibleTransfers: Omit[]; + nonFungibleTransfers: Omit[]; +}; + +export type CallContract = { + type: Intents.CALL_CONTRACT; + call: ContractFunction; +}; + +export type Intent = TransferNative | TransferErc20 | TransferErc721 | CallContract; diff --git a/packages/transaction-request-intent/src/utils/standard-functions/abis.ts b/packages/transaction-request-intent/src/utils/standard-functions/abis.ts new file mode 100644 index 000000000..284a5e339 --- /dev/null +++ b/packages/transaction-request-intent/src/utils/standard-functions/abis.ts @@ -0,0 +1,99 @@ +import { AbiParameter, } from 'viem'; + +export const Erc20TransferAbiParameters: AbiParameter[] = [ + { type: 'address', name: 'recipient' }, + { type: 'uint256', name: 'amount' }, +]; + +export const Erc20TransferFromAbiParameters: AbiParameter[] = [ + { type: 'address', name: 'sender' }, + { type: 'address', name: 'recipient' }, + { type: 'uint256', name: 'amount' }, +]; + +export const Erc20TransferAbi = { + '0xa9059cbb': Erc20TransferAbiParameters, + '0x23b872dd': Erc20TransferFromAbiParameters, +} + +export const Erc721TransferFromAbiParameters: AbiParameter[] = [ + { type: 'address', name: 'from' }, + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'tokenId' }, +]; + +export const Erc721SafeTransferFromAbiParameters: AbiParameter[] = [ + { type: 'address', name: 'from' }, + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'tokenId' }, + { type: 'bytes', name: 'data' }, +]; + +export const Erc721TransferAbi = { + '0x23b872dd': Erc721TransferFromAbiParameters, + '0x42842e0e': Erc721TransferFromAbiParameters, + '0xb88d4fde': Erc721SafeTransferFromAbiParameters, +}; + +// export const Erc1155TransferSignatures: {[key: string]: AbiParameter} = { +// "0xa22cb465": { +// "constant": false, +// "inputs": [ +// { +// "name": "from", +// "type": "address" +// }, +// { +// "name": "to", +// "type": "address" +// }, +// { +// "name": "id", +// "type": "uint256" +// }, +// { +// "name": "amount", +// "type": "uint256" +// }, +// { +// "name": "data", +// "type": "bytes" +// } +// ], +// "name": "safeTransferFrom", +// "outputs": [], +// "payable": false, +// "stateMutability": "nonpayable", +// "type": "function" +// }, +// "0xf242432a": { +// "constant": false, +// "inputs": [ +// { +// "name": "from", +// "type": "address" +// }, +// { +// "name": "to", +// "type": "address" +// }, +// { +// "name": "ids", +// "type": "uint256[]" +// }, +// { +// "name": "amounts", +// "type": "uint256[]" +// }, +// { +// "name": "data", +// "type": "bytes" +// } +// ], +// "name": "safeBatchTransferFrom", +// "outputs": [], +// "payable": false, +// "stateMutability": "nonpayable", +// "type": "function" +// } +// }; diff --git a/packages/transaction-request-intent/src/utils/standard-functions/methodId.ts b/packages/transaction-request-intent/src/utils/standard-functions/methodId.ts new file mode 100644 index 000000000..6e78203bc --- /dev/null +++ b/packages/transaction-request-intent/src/utils/standard-functions/methodId.ts @@ -0,0 +1,20 @@ +import { Erc20TransferAbi, Erc721TransferAbi } from './abis'; + +export type Erc20MethodId = keyof typeof Erc20TransferAbi; +export type Erc721MethodId = keyof typeof Erc721TransferAbi; + +export const Erc20Methods = { + TRANSFER: '0xa9059cbb', + TRANSFER_FROM: '0x23b872dd', +}; + +export const Erc721Methods = { + TRANSFER_FROM: '0x23b872dd', + SAFE_TRANSFER_FROM: '0x40c10f19', + SAFE_TRNSFER_FROM_WITH_BYTES: '0xb88d4fde', +}; + +export const Erc1155Methods = { + SAFE_TRANSFER_FROM: '0xa22cb465', + SAFE_BATCH_TRANSFER_FROM: '0xf242432a', +}; diff --git a/packages/transaction-request-intent/src/utils/standard-functions/param-extractors.ts b/packages/transaction-request-intent/src/utils/standard-functions/param-extractors.ts new file mode 100644 index 000000000..c0e050f7a --- /dev/null +++ b/packages/transaction-request-intent/src/utils/standard-functions/param-extractors.ts @@ -0,0 +1,55 @@ +import { AbiParameter, decodeAbiParameters } from 'viem'; +import { Erc20Methods } from './methodId'; +import { Erc20TransferAbi, Erc20TransferAbiParameters, Erc20TransferFromAbiParameters, Erc721TransferAbi, Erc721TransferFromAbiParameters } from './abis'; + +export function decodeAbiParametersWrapper( + params: TParams, + data: `0x${string}` +): TReturnType { + return decodeAbiParameters(params, data) as unknown as TReturnType; +} + +// To consider: if we want to typesafe the return value of decodeAbiParameters +// we end up with re-doing a lot of the work that is already done in viem +export const extractErc20TransferAmount = (data: `0x${string}`): string => { + const paramValues = decodeAbiParameters(Erc20TransferAbiParameters, data); + + const amount = paramValues[1]; + if (!amount) throw new Error('Malformed transaction request'); + return amount.toString(); +} + +export const extractErc20TransferFromAmount = (data: `0x${string}`): string => { + const paramValues = decodeAbiParameters(Erc20TransferFromAbiParameters, data); + + const amount = paramValues[2]; + if (!amount) throw new Error('Malformed transaction request'); + return amount.toString(); +} + +export const extractErc20Amount = (data: `0x${string}`, methodId: string): string => { + if (!(methodId in Erc20TransferAbi)) { + throw new Error('Invalid methodId'); + } + + switch (methodId) { + case Erc20Methods.TRANSFER: + return extractErc20TransferAmount(data); + case Erc20Methods.TRANSFER_FROM: + return extractErc20TransferFromAmount(data); + default: + throw new Error('Invalid methodId'); + } +} + +export const extractErc721AssetId = (data: `0x${string}`, methodId: string): string => { + if (!(methodId in Erc721TransferAbi)) { + throw new Error('Invalid methodId'); + } + + // No need for specific mapping here, tokenId is always the third parameter + const paramValues = decodeAbiParameters(Erc721TransferFromAbiParameters, data); + + if (!paramValues[2]) throw new Error('Malformed transaction request'); + return paramValues[2].toString(); +} diff --git a/packages/transaction-request-intent/src/utils/transaction.type.ts b/packages/transaction-request-intent/src/utils/transaction.type.ts new file mode 100644 index 000000000..6ff5863c0 --- /dev/null +++ b/packages/transaction-request-intent/src/utils/transaction.type.ts @@ -0,0 +1,20 @@ + +import { AccessList, Address, Hex } from 'viem'; + +export type TransactionRequest = { + /** Contract code or a hashed method call with encoded args */ + data?: Hex; + /** Transaction sender */ + from: Address; + /** Gas provided for transaction execution */ + gas?: TQuantity; + /** Unique number identifying this transaction */ + nonce?: TIndex; + /** Transaction recipient */ + to?: Address | null; + /** Value in wei sent with this transaction */ + value?: TQuantity; + chainId: string | null; + accessList?: AccessList; + type?: TTransactionType; +}; \ No newline at end of file