From 9c5fad383c600c33b0942623b63eb54dc48e5f07 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Thu, 24 Sep 2026 18:51:34 +0200 Subject: [PATCH 1/2] fix: create solana pending transactions --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../transactions/TransactionMapper.test.ts | 45 +++++++++++++ .../transactions/TransactionMapper.ts | 54 +++++++++++++++ .../services/wallet/WalletService.test.ts | 67 +++++++++++++++++++ .../src/core/services/wallet/WalletService.ts | 24 +++++++ .../solana-wallet-snap/src/snapContext.ts | 1 + 6 files changed, 192 insertions(+) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 727cf09c2..f16d45be6 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent signing dapp transactions with expired blockhashes, and refresh the blockhash for MetaMask-originated transactions before signing. ([#183](https://github.com/MetaMask/internal-snaps/pull/183)) - Tolerate unknown fields in the Price API spot price and Token API metadata responses so that new fields added by the API no longer fail validation ([#321](https://github.com/MetaMask/internal-snaps/pull/321)) +- Emit an `AccountTransactionsUpdated` keyring event with a pending `unconfirmed` transaction immediately after broadcasting. ## [6.0.0] diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts index 2eb47ca9b..91a4ddd78 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts @@ -1854,4 +1854,49 @@ describe('TransactionMapper', () => { }); }); }); + + describe('createPendingTransaction', () => { + it('creates a minimal unconfirmed transaction for the signature', () => { + const before = Math.floor(Date.now() / 1000); + + const result = TransactionMapper.createPendingTransaction({ + signature: 'signature-1', + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + scope: Network.Mainnet, + }); + + const after = Math.floor(Date.now() / 1000); + + expect(result).toStrictEqual({ + id: 'signature-1', + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + chain: Network.Mainnet, + status: 'unconfirmed', + type: 'unknown', + timestamp: expect.any(Number), + from: [ + { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset: { + unit: 'SOL', + type: KnownCaip19Id.SolMainnet, + amount: '0', + fungible: true, + }, + }, + ], + to: [], + fees: [], + events: [ + { + status: 'unconfirmed', + timestamp: expect.any(Number), + }, + ], + }); + + expect(result.timestamp).toBeGreaterThanOrEqual(before); + expect(result.timestamp).toBeLessThanOrEqual(after); + }); + }); }); diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts index bb8774d64..5f30f9eef 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts @@ -43,6 +43,60 @@ export class TransactionMapper { this.#logger = logger; } + /** + * Creates a minimal pending transaction for a broadcast signature. + * + * This is saved right after broadcasting, so the client can show the + * transaction (and a "submitted" toast) before the transaction is + * confirmed. It is replaced by the fully mapped transaction once the + * signature reaches the desired commitment. + * + * @param params - The parameters. + * @param params.signature - The signature of the broadcast transaction. + * @param params.account - The account that initiated the transaction. + * @param params.scope - The scope of the transaction. + * @returns A minimal pending transaction in the keyring API format. + */ + static createPendingTransaction({ + signature, + account, + scope, + }: { + signature: string; + account: ExtendedKeyringAccount; + scope: Network; + }): Transaction { + const timestamp = Math.floor(Date.now() / 1000); + + return { + id: signature, + account: account.id, + chain: scope, + status: TransactionStatus.Unconfirmed, + type: TransactionType.Unknown, + timestamp, + from: [ + { + address: account.address, + asset: { + unit: Networks[scope].nativeToken.symbol, + type: Networks[scope].nativeToken.caip19Id, + amount: '0', + fungible: true, + }, + }, + ], + to: [], + fees: [], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + }; + } + /** * Maps RPC transaction data to a standardized format. * diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index f221097b3..3cb6cfac5 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -22,6 +22,7 @@ import type { SolanaConnection } from '../connection'; import { MOCK_EXECUTION_SCENARIOS } from '../signer/mocks/scenarios'; import type { Signer } from '../signer/Signer'; import type { SignatureMonitor } from '../subscriptions'; +import type { TransactionsService } from '../transactions'; import { MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, MOCK_SIGN_IN_REQUEST, @@ -47,6 +48,7 @@ describe('WalletService', () => { let mockSigner: Signer; let mockSignatureMonitor: SignatureMonitor; let mockAnalyticsService: AnalyticsService; + let mockTransactionsService: TransactionsService; let service: WalletService; const mockAccounts = [...MOCK_SOLANA_KEYRING_ACCOUNTS]; let onCommitmentReachedCallback: (params: any) => Promise; @@ -75,11 +77,16 @@ describe('WalletService', () => { trackTransactionSubmitted: jest.fn(), } as unknown as AnalyticsService; + mockTransactionsService = { + save: jest.fn(), + } as unknown as TransactionsService; + service = new WalletService( mockConnection, mockSigner, mockSignatureMonitor, mockAnalyticsService, + mockTransactionsService, logger, ); @@ -386,6 +393,66 @@ describe('WalletService', () => { origin: 'https://metamask.io', }); }); + + it('saves a pending unconfirmed transaction after broadcasting', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + ); + + expect(mockTransactionsService.save).toHaveBeenCalledTimes(1); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: signature, + account: fromAccount.id, + chain: scope, + status: 'unconfirmed', + type: 'unknown', + from: [ + expect.objectContaining({ + address: fromAccount.address, + }), + ], + }), + ); + }); + + it('saves the pending transaction before monitoring the signature', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + ); + + const saveCallOrder = (mockTransactionsService.save as jest.Mock).mock + .invocationCallOrder[0] as number; + const monitorCallOrder = (mockSignatureMonitor.monitor as jest.Mock) + .mock.invocationCallOrder[0] as number; + + expect(saveCallOrder).toBeLessThan(monitorCallOrder); + }); + + it('does not fail when saving the pending transaction fails', async () => { + (mockTransactionsService.save as jest.Mock).mockRejectedValue( + new Error('Failed to persist pending transaction'), + ); + + const result = await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + ); + + // The transaction is already broadcast at this point, so a failure to + // persist the pending record must not fail the request, and the + // signature must still be monitored. + expect(result).toStrictEqual({ signature }); + expect(mockSignatureMonitor.monitor).toHaveBeenCalledTimes(1); + }); }); describe('signMessage', () => { diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index 6462d28e7..fe1292401 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -41,6 +41,8 @@ import { Base58Struct, Base64Struct } from '../../validation/structs'; import type { SolanaConnection } from '../connection'; import type { Signer } from '../signer/Signer'; import type { SignatureMonitor } from '../subscriptions'; +import { TransactionMapper } from '../transactions'; +import type { TransactionsService } from '../transactions'; import { SolanaSignAndSendTransactionResponseStruct, SolanaSignInResponseStruct, @@ -90,6 +92,8 @@ export class WalletService { readonly #analyticsService: AnalyticsService; + readonly #transactionsService: TransactionsService; + readonly #logger: Logger; constructor( @@ -97,12 +101,14 @@ export class WalletService { signer: Signer, signatureMonitor: SignatureMonitor, analyticsService: AnalyticsService, + transactionsService: TransactionsService, _logger = logger, ) { this.#connection = connection; this.#signer = signer; this.#signatureMonitor = signatureMonitor; this.#analyticsService = analyticsService; + this.#transactionsService = transactionsService; this.#logger = _logger.withPrefix('[👛 WalletService]'); } @@ -339,6 +345,24 @@ export class WalletService { chainIdCaip: scope, }); + // Immediately save and emit a pending transaction, so the client can show + // the transaction (and a "submitted" toast) before it is confirmed. The + // signature monitor replaces it with the fully mapped transaction once the + // signature reaches the desired commitment. + try { + await this.#transactionsService.save( + TransactionMapper.createPendingTransaction({ + signature, + account, + scope, + }), + ); + } catch (error) { + // The transaction is already broadcast, so we don't fail the request. + // The signature monitor will still save the confirmed transaction. + this.#logger.warn('Failed to save pending transaction', error); + } + await this.#signatureMonitor.monitor( signature, account.id, diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 0f370e0c6..dc1f232be 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -223,6 +223,7 @@ const walletService = new WalletService( signer, signatureMonitor, analyticsService, + transactionsService, logger, ); From 85a05f0744e58b539aeafae49e74999918315253 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Thu, 24 Sep 2026 21:15:06 +0200 Subject: [PATCH 2/2] chore: add trackError if save fails --- .../src/core/services/wallet/WalletService.test.ts | 10 ++++++++++ .../src/core/services/wallet/WalletService.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index 3cb6cfac5..f490b5083 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -16,6 +16,7 @@ import { getBip32EntropyMock, getSolanaCoinTypeNodeMock, } from '../../test/mocks/utils/getBip32Entropy'; +import { trackError } from '../../utils/errors'; import logger from '../../utils/logger'; import { createMockConnection } from '../__mocks__/mockConnection'; import type { SolanaConnection } from '../connection'; @@ -43,6 +44,10 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + describe('WalletService', () => { let mockConnection: SolanaConnection; let mockSigner: Signer; @@ -452,6 +457,11 @@ describe('WalletService', () => { // signature must still be monitored. expect(result).toStrictEqual({ signature }); expect(mockSignatureMonitor.monitor).toHaveBeenCalledTimes(1); + expect(trackError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to persist pending transaction', + }), + ); }); }); diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index fe1292401..c6e9aadbd 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -34,6 +34,7 @@ import { deriveSolanaKeypair, deriveSolanaKeypairFromCoinTypeNode, } from '../../utils/deriveSolanaKeypair'; +import { trackError } from '../../utils/errors'; import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; import logger from '../../utils/logger'; @@ -360,6 +361,7 @@ export class WalletService { } catch (error) { // The transaction is already broadcast, so we don't fail the request. // The signature monitor will still save the confirmed transaction. + await trackError(error); this.#logger.warn('Failed to save pending transaction', error); }