diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index d1576eabc..3f04e7ff0 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING** Bump `@metamask/keyring-snap-sdk` from `^9.2.1` to `^10.0.0` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) - **BREAKING** Bump `@metamask/snaps-sdk` from `^11.2.0` to `^12.0.1` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) +### Fixed + +- Show contract asset receives in transaction history instead of marking them as unknown ([#255](https://github.com/MetaMask/internal-snaps/pull/255)) + ## [0.1.0] ### Added diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts index 5a2642544..2068f1228 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts @@ -35,6 +35,8 @@ import { swapTransactionPathReceiveWithStrictPathResponse, receivePaymentTransactionPathReceiveResponse, sep41SendTransactionResponse, + contractSwapReceiveNativeTransactionResponse, + contractSwapReceiveUSDCTransactionResponse, } from './__mocks__/horizon-transaction-responses.fixtures'; import { buildMockClassicTransaction, @@ -386,6 +388,40 @@ describe('TransactionMapper', () => { fromAddress: 'GB327AMKGJDXEMQREZRRVW7Y6KEKWPOWTJKCCYUQK7KKXVMCTNZEOYXU', txnType: TransactionType.Receive, }, + { + testCase: 'receive native payment transaction via contract swap', + response: contractSwapReceiveNativeTransactionResponse, + fromAsset: getSlip44AssetId(scope), + fromAssetSymbol: NATIVE_ASSET_SYMBOL, + fromAmount: '59.1762381', + toAsset: getSlip44AssetId(scope), + toAssetSymbol: NATIVE_ASSET_SYMBOL, + toAmount: '59.1762381', + toAddress: accountAddress, + fromAddress: 'CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG', + txnType: TransactionType.Receive, + }, + { + testCase: 'receive USDC payment transaction via contract swap', + response: contractSwapReceiveUSDCTransactionResponse, + fromAsset: toCaip19ClassicAssetId( + scope, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + fromAssetSymbol: 'USDC', + fromAmount: '2.9558544', + toAsset: toCaip19ClassicAssetId( + scope, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + toAssetSymbol: 'USDC', + toAmount: '2.9558544', + toAddress: accountAddress, + fromAddress: 'CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG', + txnType: TransactionType.Receive, + }, ])( 'maps a $testCase from Horizon', ({ diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts index 2098c3d38..6df8441ad 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts @@ -8,7 +8,10 @@ import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; +import { NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES } from '../../constants'; import { + isSlip44Id, + parseClassicAssetCodeIssuer, removeTrailingZeros, stellarAssetToCaip19, toDisplayBalance, @@ -33,13 +36,19 @@ import { isReceiveOperation, isPathPaymentOperation, isInvokeHostFunctionOperation, + parseOperationAssetReferenceSafe, } from './utils'; -import type { SuccessfulTransactionResult } from './xdrParser'; +import type { + ParsedContractReceiveTransfer, + SuccessfulTransactionResult, +} from './xdrParser'; import { isSep41TransferInvoke, + parseContractEventsFromResultMeta, parseSep41TransferInvoke, parseSuccessfulTransactionResult, TransactionResultType, + parseTransferContractEventSafe, } from './xdrParser'; export class TransactionMapper { @@ -124,13 +133,15 @@ export class TransactionMapper { const { address } = keyringAccount; if (transaction.hasInvokeHostFunction && transaction.operationCount === 1) { - // Invoke host function: try SEP-41 send mapping first; fall back to unknown. + // Invoke host function: try SEP-41 send, then contract-receive from result meta. return ( this.#mapSep41SendTransaction( transaction, keyringAccount, assetMetadata, - ) ?? this.#mapUnknownTransaction(transaction, keyringAccount) + ) ?? + this.#tryMapReceiveContractTxn(transaction, keyringAccount) ?? + this.#mapUnknownTransaction(transaction, keyringAccount) ); } @@ -482,6 +493,105 @@ export class TransactionMapper { }); } + /** + * Maps an invoke-host-function credit as a receive when `result_meta_xdr` + * has a SAC `transfer` event credited to this wallet. + * + * @param transaction - On-chain invoke transaction. + * @param keyringAccount - Wallet that may have been credited. + * @returns Receive keyring transaction, or `undefined` when meta does not match. + */ + #tryMapReceiveContractTxn( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): KeyringTransaction | undefined { + // A failed transaction does not contain the result_meta_xdr for us to extract the event data. + if (transaction.status === TransactionStatus.Failed) { + return undefined; + } + + const resultMetaXdr = transaction.rawData?.result_meta_xdr; + if (!resultMetaXdr) { + return undefined; + } + + const transfers = parseContractEventsFromResultMeta({ + resultMetaXdr, + parseEvent: (event) => + parseTransferContractEventSafe(event, keyringAccount.address), + }); + + // Same as classic receive: surface the first credited asset when several match. + const [parsed] = transfers; + if (!parsed) { + return undefined; + } + + const receiveAsset = this.#mapContractTransferForNativeOrClassicAsset( + parsed, + transaction.scope, + ); + if (!receiveAsset) { + return undefined; + } + + return this.#keyringTransactionBuilder.createTransaction({ + type: KeyringTransactionType.Unknown, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + transactionType: TransactionType.Receive, + from: [ + { + address: parsed.fromAddress, + asset: receiveAsset, + }, + ], + to: [ + { + address: keyringAccount.address, + asset: receiveAsset, + }, + ], + }, + }); + } + + /** + * Resolves a SAC transfer event token topic to a keyring asset row. + * + * Only `"native"` and classic `"CODE:ISSUER"` topics are supported. + * + * @param parsed - Parsed SAC transfer from result meta. + * @param scope - CAIP-2 chain id. + * @returns Keyring asset row, or `undefined` when the token is not SAC native/classic. + */ + #mapContractTransferForNativeOrClassicAsset( + parsed: ParsedContractReceiveTransfer, + scope: KnownCaip2ChainId, + ): KeyringTransactionAsset | undefined { + const { token, amount } = parsed; + + // SAC topics use `'native'` or classic `'CODE:ISSUER'` — same shape as operation asset refs. + const classicOrNativeAssetId = parseOperationAssetReferenceSafe( + scope, + token, + ); + + if (!classicOrNativeAssetId) { + return undefined; + } + + const unit = isSlip44Id(classicOrNativeAssetId) + ? NATIVE_ASSET_SYMBOL + : parseClassicAssetCodeIssuer(token).assetCode; + + return this.#toKeyringAssetRow( + unit, + classicOrNativeAssetId, + toDisplayBalance(amount, STELLAR_DECIMAL_PLACES), + ); + } + #getCreateTime(transaction: Transaction): number { const createdAtSeconds = transaction.rawData?.created_at ? Math.floor(new Date(transaction.rawData.created_at).getTime() / 1000) diff --git a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts index 9d3430f95..bd4397cdf 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts @@ -802,3 +802,123 @@ export const sep41SendTransactionResponse = { }, }, } as unknown as Horizon.ServerApi.TransactionRecord; + +export const contractSwapReceiveNativeTransactionResponse = { + _links: { + self: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/d81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d', + }, + account: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/accounts/GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + }, + ledger: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/ledgers/64157023', + }, + operations: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/d81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/d81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions?order=asc\u0026cursor=275552315594575872', + }, + succeeds: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions?order=desc\u0026cursor=275552315594575872', + }, + transaction: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/d81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d', + }, + }, + id: 'd81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d', + paging_token: '275552315594575872', + successful: true, + hash: 'd81cd563c9354138f569eebc569f44a278b9fb79fe6b7d3286445a3575e9c58d', + ledger: 64157023, + created_at: '2026-08-28T02:27:30Z', + source_account: 'GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + source_account_sequence: '266080959223302122', + fee_account: 'GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + fee_charged: '221820', + max_fee: '761852', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAABvoRWhalyzq3GFzdiqjGcsLO5RoXIV+FP/k+EAVvgJnQALn/wDsU85AAAH6gAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAAId2l0aGRyYXcAAAAFAAAACQAAAAAAAAAAAAAAACNFk80AAAAJAAAAAAAAAGDr3rlti2+P1AAAABIAAAABJbT82FmuwvpjSEOMSJs8PBDJi20hvk/TyzDLaJU++XcAAAASAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAA0AAABBROSOJIpGEHWGwC9BHu0Pa412fK8sheSpUJctJRGTPRVkfpj98771fJVUNJlFqgQhcJC8B3l7PMz8XyHBQ0G7gwEAAAAAAAAAAAAAAQAAAAAAAAADAAAABgAAAAEltPzYWa7C+mNIQ4xImzw8EMmLbSG+T9PLMMtolT75dwAAABQAAAABAAAABgAAAAGXZfW4lXVkw3UNOcAwZyVUR4OdbqD9LX7Ttpqp5rWBCgAAABQAAAABAAAAB8x0rLVv0B71YLXUQ81Y/hHVrL7ODkABYS67MQIPyS+XAAAAAwAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAGAAAAASW0/NhZrsL6Y0hDjEibPDwQyYttIb5P08swy2iVPvl3AAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAABAAAABgAAAAGXZfW4lXVkw3UNOcAwZyVUR4OdbqD9LX7Ttpqp5rWBCgAAABAAAAABAAAAAgAAAA8AAAANV2l0aGRyYXdOb25jZQAAAAAAAAkAAAAAAAAAYOveuW2Lb4/UAAAAAQBSClgAAADAAAACGAAAAAAAA/7cAAAAAVb4CZ0AAABAh5W9CMxNdeOyGeipks289GbH024KEl7l/mTlmDW2wVVRiR/DOA2bqf8YOzHebrYKOBcCvZi8Xof8sKRHEyV1Bw==', + result_xdr: + 'AAAAAAADYnwAAAAAAAAAAQAAAAAAAAAYAAAAAD5exoUVFKIfjqtGDeDr9Pef8RxDqRRD3ewJzwkaKgziAAAAAA==', + result_meta_xdr: + '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', + fee_meta_xdr: + 'AAAAAgAAAAMD0vPjAAAAAAAAAABvoRWhalyzq3GFzdiqjGcsLO5RoXIV+FP/k+EAVvgJnQAAAAAlIuASA7FPOQAAB+kAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPS8+MAAAAAapDpmQAAAAAAAAABA9L1XwAAAAAAAAAAb6EVoWpcs6txhc3YqoxnLCzuUaFyFfhT/5PhAFb4CZ0AAAAAJR7g0gOxTzkAAAfpAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAD0vPjAAAAAGqQ6ZkAAAAA', + memo_type: 'none', + signatures: [ + 'h5W9CMxNdeOyGeipks289GbH024KEl7l/mTlmDW2wVVRiR/DOA2bqf8YOzHebrYKOBcCvZi8Xof8sKRHEyV1Bw==', + ], + preconditions: { + timebounds: { + min_time: '0', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const contractSwapReceiveUSDCTransactionResponse = { + _links: { + self: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869', + }, + account: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/accounts/GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + }, + ledger: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/ledgers/64236092', + }, + operations: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions?order=asc\u0026cursor=275891914363961344', + }, + succeeds: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions?order=desc\u0026cursor=275891914363961344', + }, + transaction: { + href: 'https://archive.stellar.validationcloud.io/v1/wd71cvymMEBizGDwPgpah1KH6Ao6kGP5lYpZewqWi2U/transactions/c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869', + }, + }, + id: 'c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869', + paging_token: '275891914363961344', + successful: true, + hash: 'c076b610a6c55ae8bb7f14ae9a913b82442baac4c9afe61762e6d67479217869', + ledger: 64236092, + created_at: '2026-09-02T07:27:39Z', + source_account: 'GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + source_account_sequence: '266080959223302357', + fee_account: 'GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + fee_charged: '222205', + max_fee: '762276', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAABvoRWhalyzq3GFzdiqjGcsLO5RoXIV+FP/k+EAVvgJnQALoaQDsU85AAAI1QAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAAId2l0aGRyYXcAAAAFAAAACQAAAAAAAAAAAAAAAAHDBxAAAAAJAAAAAAAAAGDyHYQ7zUJPrAAAABIAAAABre/OWa7lKWj3YGHUlMJSW3Vln6QpamX0me8p5WR35JYAAAASAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAA0AAABBfjHGB+REPo60ovPxVjS8eb0AqGzfHGTMyA6wPTvaOVt88M+e1crlSAiqb9OBOPgq6z6eaIAVbJALC7iZHymgrwEAAAAAAAAAAAAAAQAAAAAAAAADAAAABgAAAAGXZfW4lXVkw3UNOcAwZyVUR4OdbqD9LX7Ttpqp5rWBCgAAABQAAAABAAAABgAAAAGt785ZruUpaPdgYdSUwlJbdWWfpClqZfSZ7ynlZHfklgAAABQAAAABAAAAB8x0rLVv0B71YLXUQ81Y/hHVrL7ODkABYS67MQIPyS+XAAAAAwAAAAEAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAABVVNEQwAAAAA7mRE4Dv6Yi6CokA6xz+RPNm99vpRr7QdyQPf2JN8VxQAAAAYAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAAQAAAAAQAAAAIAAAAPAAAADVdpdGhkcmF3Tm9uY2UAAAAAAAAJAAAAAAAAAGDyHYQ7zUJPrAAAAAEAAAAGAAAAAa3vzlmu5Slo92Bh1JTCUlt1ZZ+kKWpl9JnvKeVkd+SWAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAABAFJpQQAAAHQAAAHMAAAAAAAEAIQAAAABVvgJnQAAAEDoyzcNlloQIahqoLe9fe6WcagM+6KRVmfdNuskIA6jAQgCR76kaLK05VUiKSVCgfn15YfIWYEEm1WZJQvhZdAC', + result_xdr: + 'AAAAAAADY/0AAAAAAAAAAQAAAAAAAAAYAAAAAMVpgkEn0Gzs9va0NuidPSc3BDqo2Uw3/wRpopyW42PXAAAAAA==', + result_meta_xdr: + 'AAAABAAAAAAAAAACAAAAAwPUKjwAAAAAAAAAAG+hFaFqXLOrcYXN2KqMZyws7lGhchX4U/+T4QBW+AmdAAAAACIChjYDsU85AAAI1AAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAA9QmZgAAAABql7oRAAAAAAAAAAED1Co8AAAAAAAAAABvoRWhalyzq3GFzdiqjGcsLO5RoXIV+FP/k+EAVvgJnQAAAAAiAoY2A7FPOQAACNUAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPUKjwAAAAAapfP6wAAAAAAAAABAAAAAAAAAAYAAAADA9Qp3QAAAAYAAAAAAAAAAa3vzlmu5Slo92Bh1JTCUlt1ZZ+kKWpl9JnvKeVkd+SWAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAABAAAAEQAAAAEAAAADAAAADwAAAAZhbW91bnQAAAAAAAoAAAAAAAAAAAAAJZ6OFahbAAAADwAAAAphdXRob3JpemVkAAAAAAAAAAAAAQAAAA8AAAAIY2xhd2JhY2sAAAAAAAAAAAAAAAAAAAABA9QqPAAAAAYAAAAAAAAAAa3vzlmu5Slo92Bh1JTCUlt1ZZ+kKWpl9JnvKeVkd+SWAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAABAAAAEQAAAAEAAAADAAAADwAAAAZhbW91bnQAAAAAAAoAAAAAAAAAAAAAJZ6MUqFLAAAADwAAAAphdXRob3JpemVkAAAAAAAAAAAAAQAAAA8AAAAIY2xhd2JhY2sAAAAAAAAAAAAAAAAAAAAAA9QqPAAAAAmxr0PFipYOeHmwD+xXMdilq5AGi8seF2KEnetJ4fOH1APzzjsAAAAAAAAAAAPUKjwAAAAGAAAAAAAAAAGXZfW4lXVkw3UNOcAwZyVUR4OdbqD9LX7Ttpqp5rWBCgAAABAAAAABAAAAAgAAAA8AAAANV2l0aGRyYXdOb25jZQAAAAAAAAkAAAAAAAAAYPIdhDvNQk+sAAAAAQAAAAAAAAABAAAAAAAAAAMD1AICAAAAAQAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAFVU0RDAAAAADuZETgO/piLoKiQDrHP5E82b32+lGvtB3JA9/Yk3xXFAAAAAAAAAAB//////////wAAAAEAAAAAAAAAAAAAAAED1Co8AAAAAQAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAFVU0RDAAAAADuZETgO/piLoKiQDrHP5E82b32+lGvtB3JA9/Yk3xXFAAAAAAHDBxB//////////wAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAa3vzlmu5Slo92Bh1JTCUlt1ZZ+kKWpl9JnvKeVkd+SWAAAAAQAAAAAAAAAEAAAADwAAAAh0cmFuc2ZlcgAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAASAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAA4AAAA9VVNEQzpHQTVaU0VKWUIzN0pSQzVBVkNJQTVNT1A0UkhUTTMzNVgyS0dYM0lIT0pBUFA1UkUzNEs0S1pWTgAAAAAAAAoAAAAAAAAAAAAAAAABwwcQAAAAAAAAAAEAAAAAAAAAAQAAAAEAAAAAAAAAAA==', + fee_meta_xdr: + 'AAAAAgAAAAMD1CZmAAAAAAAAAABvoRWhalyzq3GFzdiqjGcsLO5RoXIV+FP/k+EAVvgJnQAAAAAiBoceA7FPOQAACNQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPUJmYAAAAAape6EQAAAAAAAAABA9QqPAAAAAAAAAAAb6EVoWpcs6txhc3YqoxnLCzuUaFyFfhT/5PhAFb4CZ0AAAAAIgKGNgOxTzkAAAjUAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAD1CZmAAAAAGqXuhEAAAAA', + memo_type: 'none', + signatures: [ + '6Ms3DZZaECGoaqC3vX3ulnGoDPuikVZn3TbrJCAOowEIAke+pGiytOVVIiklQoH59eWHyFmBBJtVmSUL4WXQAg==', + ], + preconditions: { + timebounds: { + min_time: '0', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; diff --git a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.test.ts b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.test.ts index e6a2a8d04..ae9b63dfd 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.test.ts @@ -14,6 +14,7 @@ import { swapTransactionPathReceiveResponse, swapTransactionWithFeeCollectResponse, swapTransactionWithoutFeeCollectResponse, + contractSwapReceiveNativeTransactionResponse, } from './__mocks__/horizon-transaction-responses.fixtures'; import { buildMockInvokeHostFunctionTransaction } from './__mocks__/transaction.fixtures'; import type { MockInvokeHostFunctionArgNativeToScValOptions } from './__mocks__/transaction.fixtures'; @@ -21,12 +22,14 @@ import { XdrParseException } from './exceptions'; import { isSep41TransferInvoke, nativeToReadableJson, + parseContractEventsFromResultMeta, parseSep41TransferInvoke, parseSuccessfulTransactionResult, parseScValToReadableJson, getAddress, getFunctionName, TransactionResultType, + parseTransferContractEventSafe, xdrAssetToCaip19, } from './xdrParser'; @@ -397,4 +400,49 @@ describe('transaction-xdr-decoder', () => { ); }); }); + + describe('parseContractEventsFromResultMeta', () => { + it('accumulates native SAC transfers credited to the wallet', () => { + const results = parseContractEventsFromResultMeta({ + resultMetaXdr: + contractSwapReceiveNativeTransactionResponse.result_meta_xdr, + parseEvent: (event) => + parseTransferContractEventSafe(event, accountAddress), + }); + + expect(results).toStrictEqual([ + { + fromAddress: + 'CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG', + toAddress: accountAddress, + token: 'native', + amount: new BigNumber('591762381'), + }, + ]); + }); + + it('returns an empty array when the wallet is not the transfer recipient', () => { + expect( + parseContractEventsFromResultMeta({ + resultMetaXdr: + contractSwapReceiveNativeTransactionResponse.result_meta_xdr, + parseEvent: (event) => + parseTransferContractEventSafe( + event, + 'GBX2CFNBNJOLHK3RQXG5RKUMM4WCZ3SRUFZBL6CT76J6CACW7AEZ3SHN', + ), + }), + ).toStrictEqual([]); + }); + + it('returns an empty array for invalid meta xdr', () => { + expect( + parseContractEventsFromResultMeta({ + resultMetaXdr: 'not-valid-xdr', + parseEvent: (event) => + parseTransferContractEventSafe(event, accountAddress), + }), + ).toStrictEqual([]); + }); + }); }); diff --git a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts index 1d59757c4..c74305772 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts @@ -83,6 +83,31 @@ export type ParsedSep41TransferInvoke = { amount: BigNumber; }; +/** + * SAC `transfer` contract event credited to a wallet, parsed from `result_meta_xdr`. + * + * Topics follow Stellar Asset Contract events: + * `["transfer", from, to, token]` with `data` = amount in smallest units. + * `token` is `"native"` or classic `"CODE:ISSUER"` (SEP-41 wasm events use 3 topics and are not parsed here). + */ +export type ParsedContractReceiveTransfer = { + fromAddress: string; + toAddress: string; + /** + * Fourth SAC topic: `"native"` or `"CODE:ISSUER"`. + */ + token: string; + amount: BigNumber; +}; + +/** + * Caller-supplied parser for a single contract event from transaction meta. + * Return a value to keep it, or `null` to skip. + */ +export type ContractEventParser = ( + event: xdr.ContractEvent, +) => Result | null; + /** * Parses a successful Stellar transaction result XDR into operation-level outcomes. * @@ -531,6 +556,132 @@ export function extractAssetDataFromContractData( } } +/** + * Best-effort walk of contract events in Horizon `result_meta_xdr`. + * + * The caller supplies {@link ContractEventParser} for the event shape they care + * about (e.g. {@link parseTransferContractEventSafe}). Matching results are + * accumulated in order; the caller decides how to reduce the array. + * + * @param params - Meta XDR and event parser. + * @param params.resultMetaXdr - Base64 `TransactionMeta` from Horizon. + * @param params.parseEvent - Returns a value to keep, or `null` to skip. + * @returns Parsed values in event order; empty when meta is missing/invalid or nothing matches. + */ +export function parseContractEventsFromResultMeta(params: { + resultMetaXdr: string; + parseEvent: ContractEventParser; +}): Result[] { + try { + const { resultMetaXdr, parseEvent } = params; + const meta = xdr.TransactionMeta.fromXDR( + bufferToUint8Array(resultMetaXdr, 'base64'), + ); + + const results: Result[] = []; + + let events: xdr.ContractEvent[] = []; + + // Handle both V3 and V4 transaction meta formats + switch (meta.switch()) { + case 3: { + const sorobanMeta = meta.v3().sorobanMeta(); + events = sorobanMeta ? [...sorobanMeta.events()] : []; + break; + } + case 4: { + events = meta + .v4() + .operations() + .flatMap((op) => [...op.events()]); + break; + } + default: + break; + } + + for (const event of events) { + const parsed = parseEvent(event); + if (parsed !== null) { + results.push(parsed); + } + } + return results; + } catch { + // Best effort: corrupt or unexpected meta must not fail mapping. + return []; + } +} + +/** + * Parses a SAC `transfer` contract event credited to `accountAddress`. + * + * Expects topics `["transfer", from, to, token]` (4 topics). SEP-41 wasm + * tokens emit 3-topic events and are skipped. + * + * Suitable as a {@link ContractEventParser} via + * `(event) => parseTransferContractEventSafe(event, accountAddress)`. + * + * @param event - Contract event from transaction meta. + * @param accountAddress - Expected recipient address. + * @returns Parsed transfer, or `null` when topics/data do not match. + */ +export function parseTransferContractEventSafe( + event: xdr.ContractEvent, + accountAddress: string, +): ParsedContractReceiveTransfer | null { + try { + const body = event.body(); + if (body.switch() !== 0) { + return null; + } + + const v0 = body.v0(); + const topics = v0.topics().map((topic) => scValToNative(topic)); + if (topics.length !== 4) { + return null; + } + + const [eventName, fromAddress, toAddress, token] = topics; + if ( + eventName !== 'transfer' || + typeof fromAddress !== 'string' || + typeof toAddress !== 'string' || + typeof token !== 'string' || + toAddress !== accountAddress + ) { + return null; + } + + // We only support transfers from and to Stellar addresses or contract addresses + // Muxed is blocked for now + if ( + !StellarAddressOrContractStruct.is(fromAddress) || + !StellarAddressOrContractStruct.is(toAddress) + ) { + return null; + } + + const transferAmount = scValToNative(v0.data()); + if ( + typeof transferAmount !== 'bigint' && + typeof transferAmount !== 'number' && + typeof transferAmount !== 'string' + ) { + return null; + } + + return { + fromAddress, + toAddress, + token, + amount: parseScValToNative(transferAmount), + }; + } catch { + return null; + } +} + /** * Encodes an XDR public key as a Stellar account address. *