Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions packages/snap/src/services/network/NetworkService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
53 changes: 44 additions & 9 deletions packages/snap/src/services/network/NetworkService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +32,7 @@ import {
StellarRouterContract,
} from './MultiCall';
import {
assertNetworkResponse,
baseInclusionFee,
isAccountNotFoundError,
sep41MulticallCellToBalance,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -225,18 +238,21 @@ 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,
scope: KnownCaip2ChainId,
): Promise<OnChainAccount> {
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, {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions packages/snap/src/services/network/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import {
array,
boolean,
number,
optional,
record,
string,
type,
union,
} from '@metamask/superstruct';

import type { KnownCaip19AssetId } from '../../api';

/**
Expand Down Expand Up @@ -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(),
}),
),
});
23 changes: 23 additions & 0 deletions packages/snap/src/services/network/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +16,26 @@ const StellarNetwork: Record<KnownCaip2ChainId, Networks> = {
[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<Validated>(
response: unknown,
struct: Struct<Validated>,
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).
*
Expand Down
Loading