Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/solana-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ 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';
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,
Expand All @@ -42,11 +44,16 @@ 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;
let mockSignatureMonitor: SignatureMonitor;
let mockAnalyticsService: AnalyticsService;
let mockTransactionsService: TransactionsService;
let service: WalletService;
const mockAccounts = [...MOCK_SOLANA_KEYRING_ACCOUNTS];
let onCommitmentReachedCallback: (params: any) => Promise<void>;
Expand Down Expand Up @@ -75,11 +82,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,
);

Expand Down Expand Up @@ -386,6 +398,71 @@ 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);
expect(trackError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to persist pending transaction',
}),
);
});
});

describe('signMessage', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ 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';
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,
Expand Down Expand Up @@ -90,19 +93,23 @@ export class WalletService {

readonly #analyticsService: AnalyticsService;

readonly #transactionsService: TransactionsService;

readonly #logger: Logger;

constructor(
connection: SolanaConnection,
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]');
}

Expand Down Expand Up @@ -339,6 +346,25 @@ 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.
await trackError(error);
this.#logger.warn('Failed to save pending transaction', error);
Comment thread
taran-a marked this conversation as resolved.
}

await this.#signatureMonitor.monitor(
signature,
account.id,
Expand Down
1 change: 1 addition & 0 deletions packages/solana-wallet-snap/src/snapContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ const walletService = new WalletService(
signer,
signatureMonitor,
analyticsService,
transactionsService,
logger,
);

Expand Down
Loading