diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 97b74c41..cc023b37 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "1ftcq5ewpbXIRZpVifhbpo4kHtdvXB4MEoo+bgjMWGQ=", + "shasum": "2tw7w7fDjzW3AnygDhJLwRpiFYXMGKLeAKcuDcdWpvA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/services/network/NetworkService.test.ts b/packages/snap/src/services/network/NetworkService.test.ts index 443eff67..1d1430a5 100644 --- a/packages/snap/src/services/network/NetworkService.test.ts +++ b/packages/snap/src/services/network/NetworkService.test.ts @@ -266,6 +266,19 @@ describe('NetworkService', () => { networkService.loadOnChainAccount(testAddress, scope), ).rejects.toThrow(NetworkServiceException); }); + + it('throws NetworkServiceException when Horizon returns a malformed balance line', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockResolvedValue({ + accountId: () => testAddress, + sequenceNumber: () => '1', + balances: [{ balance: 100, asset_type: 'native' }], + } as unknown as StellarHorizon.AccountResponse); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow('Invalid Horizon account response'); + }); }); describe('loadOnChainAccountWithCache', () => { @@ -569,6 +582,23 @@ describe('NetworkService', () => { assetsSpy.mockRestore(); }); + + it('throws NetworkServiceException when Horizon omits the assets records', async () => { + const call = jest.fn().mockResolvedValue({}); + const assetsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'assets') + .mockReturnValue({ + forCode: jest.fn().mockReturnValue({ + forIssuer: jest.fn().mockReturnValue({ call }), + }), + } as never); + + await expect( + networkService.getClassicAssetData(classicAssetId, scope), + ).rejects.toThrow('Invalid Horizon assets response'); + + assetsSpy.mockRestore(); + }); }); describe('pollTransaction', () => { @@ -703,6 +733,24 @@ describe('NetworkService', () => { transactionsSpy.mockRestore(); }); + + it('throws NetworkServiceException when Horizon omits the ledger outcome', async () => { + const call = jest.fn().mockResolvedValue({}); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + await expect( + networkService.getHorizonTransactionInclusionStatus( + testTransactionHash, + scope, + ), + ).rejects.toThrow('Invalid Horizon transaction response'); + + transactionsSpy.mockRestore(); + }); }); describe('getTransaction', () => { @@ -744,6 +792,23 @@ describe('NetworkService', () => { transactionsSpy.mockRestore(); }); + + it('throws NetworkServiceException when the Horizon record omits the envelope', async () => { + const { envelope_xdr: _omitted, ...recordWithoutEnvelope } = + buildMockHorizonTransactionRecord(); + const call = jest.fn().mockResolvedValue(recordWithoutEnvelope); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + await expect( + networkService.getTransaction(testTransactionHash, scope), + ).rejects.toThrow('Invalid Horizon transaction response'); + + transactionsSpy.mockRestore(); + }); }); describe('getTransactions', () => { @@ -1007,6 +1072,22 @@ describe('NetworkService', () => { transactionsSpy.mockRestore(); }); + + it('throws NetworkServiceException when the Horizon page omits the records', async () => { + const call = jest.fn().mockResolvedValue({}); + const transactionsSpy = mockHorizonAccountTransactions(call); + + await expect( + networkService.getTransactions({ + accountAddress: generateStellarAddress(), + lastScanToken: '', + scope, + order: 'asc', + }), + ).rejects.toThrow('Invalid Horizon transactions response'); + + transactionsSpy.mockRestore(); + }); }); describe('send', () => { diff --git a/packages/snap/src/services/network/NetworkService.ts b/packages/snap/src/services/network/NetworkService.ts index 2f285455..40aa1d1d 100644 --- a/packages/snap/src/services/network/NetworkService.ts +++ b/packages/snap/src/services/network/NetworkService.ts @@ -8,7 +8,14 @@ import { } from '@stellar/stellar-sdk'; import type { AssetDataResponse } from './api'; -import { KnownRpcError } from './api'; +import { + HorizonAccountResponseStruct, + HorizonAssetPageStruct, + HorizonTransactionInclusionStruct, + HorizonTransactionPageStruct, + HorizonTransactionRecordStruct, + KnownRpcError, +} from './api'; import { AccountNotActivatedException, NetworkServiceException, @@ -25,6 +32,7 @@ import { StellarRouterContract, } from './MultiCall'; import { + assertNetworkResponse, baseInclusionFee, isAccountNotFoundError, sep41MulticallCellToBalance, @@ -163,7 +171,7 @@ export class NetworkService { * @param transactionHash - Transaction hash from submission (hex). * @param scope - CAIP-2 chain id (Horizon endpoint). * @returns `pending` when the tx is not yet available (404); `success` / `failed` when present. - * @throws {NetworkServiceException} When Horizon returns a non-404 error. + * @throws {NetworkServiceException} When Horizon returns a non-404 error or an invalid record. */ async getHorizonTransactionInclusionStatus( transactionHash: string, @@ -175,6 +183,11 @@ export class NetworkService { .transactions() .transaction(transactionHash) .call(); + assertNetworkResponse( + record, + HorizonTransactionInclusionStruct, + 'Invalid Horizon transaction response', + ); return record.successful ? 'success' : 'failed'; } catch (error: unknown) { if (error instanceof NotFoundError) { @@ -225,7 +238,7 @@ export class NetworkService { * @param scope - The CAIP-2 chain ID. * @returns A Promise that resolves to a {@link OnChainAccount} backed by Horizon's account response. * @throws {AccountNotActivatedException} If the account does not exist on the network. - * @throws {NetworkServiceException} If loading fails for another reason (e.g. network error). + * @throws {NetworkServiceException} If loading fails for another reason (e.g. network error) or the response is invalid. */ async loadOnChainAccount( accountAddress: string, @@ -233,10 +246,13 @@ export class NetworkService { ): Promise { try { const client = this.#getHorizonClient(scope); - return OnChainAccount.fromHorizon( - await client.loadAccount(accountAddress), - scope, + const response = await client.loadAccount(accountAddress); + assertNetworkResponse( + response, + HorizonAccountResponseStruct, + 'Invalid Horizon account response', ); + return OnChainAccount.fromHorizon(response, scope); } catch (error: unknown) { if (isAccountNotFoundError(error, accountAddress)) { throw new AccountNotActivatedException(accountAddress, scope, { @@ -428,7 +444,7 @@ export class NetworkService { * @param assetId - CAIP-19 classic asset id (`…/asset:CODE-ISSUER`). * @param scope - The CAIP-2 chain ID. * @returns for the classic asset. - * @throws {NetworkServiceException} When Horizon returns no entry for this asset or the request fails. + * @throws {NetworkServiceException} When Horizon returns no entry for this asset, the response is invalid, or the request fails. */ async getClassicAssetData( assetId: KnownCaip19ClassicAssetId, @@ -444,9 +460,13 @@ export class NetworkService { .forCode(assetCode) .forIssuer(assetIssuer) .call(); + assertNetworkResponse( + assetData, + HorizonAssetPageStruct, + 'Invalid Horizon assets response', + ); if ( - !assetData || assetData.records.length === 0 || assetData.records[0]?.asset_code !== assetCode || assetData.records[0]?.asset_issuer !== assetIssuer @@ -753,7 +773,7 @@ export class NetworkService { * @param scope - CAIP-2 network scope used to choose the Horizon client and decode envelope XDR. * @returns The mapped {@link Transaction}. * @throws {TransactionNotFoundException} When Horizon reports the transaction is not found. - * @throws {NetworkServiceException} When the transaction cannot be fetched or mapped for another reason. + * @throws {NetworkServiceException} When the transaction cannot be fetched, fails validation, or cannot be mapped. */ async getTransaction( transactionHash: string, @@ -765,6 +785,11 @@ export class NetworkService { .transactions() .transaction(transactionHash) .call(); + assertNetworkResponse( + result, + HorizonTransactionRecordStruct, + 'Invalid Horizon transaction response', + ); return this.#toTransaction(result, scope); } catch (error: unknown) { if (error instanceof NotFoundError) { @@ -839,6 +864,11 @@ export class NetworkService { .limit(pageSize) .includeFailed(includeFailed) .call(); + assertNetworkResponse( + initialTransactionsResponse, + HorizonTransactionPageStruct, + 'Invalid Horizon transactions response', + ); let transactions = this.#toTransactions( initialTransactionsResponse.records, @@ -857,6 +887,11 @@ export class NetworkService { currentResponse.records.length === pageSize ) { currentResponse = await currentResponse.next(); + assertNetworkResponse( + currentResponse, + HorizonTransactionPageStruct, + 'Invalid Horizon transactions response', + ); if (currentResponse.records.length === 0) { break; diff --git a/packages/snap/src/services/network/api.ts b/packages/snap/src/services/network/api.ts index 9cb281f3..b15d4f80 100644 --- a/packages/snap/src/services/network/api.ts +++ b/packages/snap/src/services/network/api.ts @@ -1,3 +1,14 @@ +import { + array, + boolean, + number, + optional, + record, + string, + type, + union, +} from '@metamask/superstruct'; + import type { KnownCaip19AssetId } from '../../api'; /** @@ -32,3 +43,61 @@ export type AssetDataResponse = { // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract assetId: KnownCaip19AssetId; }; + +/** + * Validation structs for the fields the snap consumes from raw Horizon JSON responses. + * Responses the SDK already validates on construction (base `Account` from `loadAccount` + * / RPC `getAccount`, XDR-parsed RPC results) are not re-validated here. + * + * They intentionally use `type` (not `object`) so that unlisted fields of these + * responses are neither rejected nor stripped. + */ + +/** Horizon account balance line. */ +export const HorizonBalanceLineStruct = type({ + balance: string(), + asset_type: string(), + asset_code: optional(string()), + asset_issuer: optional(string()), + limit: optional(string()), + is_authorized: optional(boolean()), + sponsor: optional(string()), +}); + +/** Horizon `loadAccount` response fields (id and sequence are validated by the SDK constructor). */ +export const HorizonAccountResponseStruct = type({ + subentry_count: optional(number()), + num_sponsoring: optional(number()), + num_sponsored: optional(number()), + data_attr: optional(record(string(), string())), + balances: optional(array(HorizonBalanceLineStruct)), +}); + +/** Horizon transaction record. */ +export const HorizonTransactionRecordStruct = type({ + envelope_xdr: string(), + fee_charged: union([string(), number()]), + successful: boolean(), + source_account: string(), + paging_token: string(), +}); + +/** Horizon transaction record, reduced to the ledger outcome. */ +export const HorizonTransactionInclusionStruct = type({ + successful: boolean(), +}); + +/** Horizon transactions collection page. */ +export const HorizonTransactionPageStruct = type({ + records: array(HorizonTransactionRecordStruct), +}); + +/** Horizon assets collection page. */ +export const HorizonAssetPageStruct = type({ + records: array( + type({ + asset_code: string(), + asset_issuer: string(), + }), + ), +}); diff --git a/packages/snap/src/services/network/utils.ts b/packages/snap/src/services/network/utils.ts index 9852cdc3..06119fc7 100644 --- a/packages/snap/src/services/network/utils.ts +++ b/packages/snap/src/services/network/utils.ts @@ -1,7 +1,10 @@ +import type { Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; import { ensureError } from '@metamask/utils'; import { Networks, NotFoundError } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { NetworkServiceException } from './exceptions'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { BASE_FEE } from '../../constants'; @@ -13,6 +16,26 @@ const StellarNetwork: Record = { [KnownCaip2ChainId.Testnet]: Networks.TESTNET, }; +/** + * Validates the fields the snap consumes from a Horizon response. + * + * @param response - Response returned by the Horizon client. + * @param struct - Validation struct for the consumed fields. + * @param message - Message of the thrown exception. + * @throws {NetworkServiceException} When `response` does not match `struct`. + */ +export function assertNetworkResponse( + response: unknown, + struct: Struct, + message: string, +): void { + try { + assert(response, struct); + } catch (error: unknown) { + throw new NetworkServiceException(message, { cause: error }); + } +} + /** * Returns the Stellar network passphrase for the given scope (e.g. for transaction building). *