diff --git a/package.json b/package.json index 190f1ebb..0a88694b 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "packageManager": "yarn@4.17.0", "engines": { - "node": ">= 20" + "node": ">= 22" }, "lavamoat": { "allowScripts": { diff --git a/packages/snap/jest.config.js b/packages/snap/jest.config.js index 859b3a37..868d10fe 100644 --- a/packages/snap/jest.config.js +++ b/packages/snap/jest.config.js @@ -42,8 +42,18 @@ const config = { preset: '@metamask/snaps-jest', transform: { - '^.+\\.(t|j)sx?$': 'ts-jest', + '^.+\\.(t|j)sx?$': [ + 'ts-jest', + { + tsconfig: { + allowJs: true, + }, + }, + ], }, + transformIgnorePatterns: [ + '/node_modules/(?!(@noble/ed25519|@noble/hashes|@stellar/stellar-sdk|uint8array-extras)/)', + ], moduleNameMapper: { '\\.svg$': 'jest-transform-stub', }, diff --git a/packages/snap/package.json b/packages/snap/package.json index 658d982d..a54caf26 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -51,7 +51,7 @@ "@metamask/snaps-sdk": "^11.1.0", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.11.0", - "@stellar/stellar-sdk": "^15.0.1", + "@stellar/stellar-sdk": "^16.0.1", "@types/jest": "^30.0.0", "async-mutex": "^0.5.0", "bignumber.js": "^9.3.1", diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 8177ac6d..7bed8e44 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": "Inf8VxNlsHTyLYST3nsK1X0sdkSiAbkFciLrCTsYZNY=", + "shasum": "SVKNK5OF7tekFDnKaeS+aIitTtVULg/J9z4/N22oDTI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/services/network/HorizonClient.test.ts b/packages/snap/src/services/network/HorizonClient.test.ts new file mode 100644 index 00000000..ffde8211 --- /dev/null +++ b/packages/snap/src/services/network/HorizonClient.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Horizon wire fields use snake_case */ +import { HorizonClient, HorizonNotFoundError } from './HorizonClient'; + +describe('HorizonClient', () => { + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn< + ReturnType, + Parameters + >(); + + beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock; + }); + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + it('fetches the base fee from Horizon fee stats', async () => { + fetchMock.mockResolvedValue(jsonResponse({ last_ledger_base_fee: '123' })); + const client = new HorizonClient('https://horizon.example'); + + const result = await client.fetchBaseFee(); + + expect(result).toBe(123); + expect(fetchMock).toHaveBeenCalledWith( + 'https://horizon.example/fee_stats', + { + method: 'GET', + headers: { Accept: 'application/json' }, + }, + ); + }); + + it('loads account responses with SDK-compatible account helpers', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + account_id: 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + sequence: '42', + balances: [], + }), + ); + const client = new HorizonClient('https://horizon.example'); + + const result = await client.loadAccount( + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + ); + + expect(result.accountId()).toBe( + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + ); + expect(result.sequenceNumber()).toBe('42'); + }); + + it('reads asset records from Horizon embedded collection responses', async () => { + const assetRecord = { + asset_code: 'USDC', + asset_issuer: 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + }; + fetchMock.mockResolvedValue( + jsonResponse({ + _embedded: { + records: [assetRecord], + }, + }), + ); + const client = new HorizonClient('https://horizon.example'); + + const result = await client.getAssetRecords({ + assetCode: 'USDC', + assetIssuer: 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + }); + + expect(result.records).toStrictEqual([assetRecord]); + }); + + it('reads transaction records from Horizon embedded collection responses', async () => { + const transactionRecord = { + hash: 'transaction-hash', + source_account: + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + }; + fetchMock.mockResolvedValue( + jsonResponse({ + _links: { + next: { + href: '', + }, + }, + _embedded: { + records: [transactionRecord], + }, + }), + ); + const client = new HorizonClient('https://horizon.example'); + + const result = await client.getTransactions({ + accountAddress: + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + cursor: '', + includeFailed: false, + limit: 10, + order: 'desc', + }); + + expect(result.records).toStrictEqual([transactionRecord]); + }); + + it('throws HorizonNotFoundError for 404 responses', async () => { + fetchMock.mockResolvedValue(jsonResponse({ title: 'Not Found' }, 404)); + const client = new HorizonClient('https://horizon.example'); + + await expect(client.getTransaction('abc')).rejects.toThrow( + HorizonNotFoundError, + ); + }); +}); + +function jsonResponse(body: unknown, status: number = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body), + } as Response; +} diff --git a/packages/snap/src/services/network/HorizonClient.ts b/packages/snap/src/services/network/HorizonClient.ts new file mode 100644 index 00000000..24730fd3 --- /dev/null +++ b/packages/snap/src/services/network/HorizonClient.ts @@ -0,0 +1,221 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Horizon wire fields use snake_case */ +import type { Horizon } from '@stellar/stellar-sdk'; + +type HorizonAccountJson = Horizon.AccountResponse & { + account_id?: string; + id?: string; + sequence?: string; +}; + +type HorizonCollectionResponse = { + records?: TRecord[]; + _embedded?: { records?: TRecord[] }; + _links?: { + next?: { + href?: string; + }; + }; +}; + +export type HorizonAssetRecord = { + asset_code?: string; + asset_issuer?: string; +}; + +export type HorizonAssetRecordsResponse = { + records: HorizonAssetRecord[]; +}; + +export type HorizonTransactionPage = { + records: Horizon.ServerApi.TransactionRecord[]; + next: () => Promise; +}; + +/** + * Error thrown when Horizon returns HTTP 404. + */ +export class HorizonNotFoundError extends Error { + readonly status = 404; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'HorizonNotFoundError'; + } +} + +/** + * Small Snap-safe Horizon client using the platform `fetch` endowment directly. + */ +export class HorizonClient { + readonly #baseUrl: string; + + constructor(baseUrl: string) { + this.#baseUrl = baseUrl.replace(/\/$/u, ''); + } + + async fetchBaseFee(): Promise { + const feeStats = await this.#requestJson<{ + last_ledger_base_fee?: string; + }>('/fee_stats'); + return parseInt(feeStats.last_ledger_base_fee ?? '', 10) || 100; + } + + async loadAccount(accountAddress: string): Promise { + const account = await this.#requestJson( + `/accounts/${encodeURIComponent(accountAddress)}`, + ); + return this.#toAccountResponse(account); + } + + async getAssetRecords(params: { + assetCode: string; + assetIssuer: string; + }): Promise { + const { assetCode, assetIssuer } = params; + const response = await this.#requestJson< + HorizonCollectionResponse + >( + `/assets?${encodeQuery({ + asset_code: assetCode, + asset_issuer: assetIssuer, + })}`, + ); + + return { + records: response.records ?? response._embedded?.records ?? [], + }; + } + + async getTransaction( + transactionHash: string, + ): Promise { + return this.#requestJson( + `/transactions/${encodeURIComponent(transactionHash)}`, + ); + } + + async getTransactions(params: { + accountAddress: string; + cursor: string; + includeFailed: boolean; + limit: number; + order: 'asc' | 'desc'; + }): Promise { + const { accountAddress, cursor, includeFailed, limit, order } = params; + return this.#getTransactionPage( + `/accounts/${encodeURIComponent(accountAddress)}/transactions?${encodeQuery( + { + cursor, + include_failed: includeFailed, + limit, + order, + }, + )}`, + ); + } + + async #getTransactionPage(url: string): Promise { + const response = + await this.#requestJson< + HorizonCollectionResponse + >(url); + const nextUrl = response._links?.next?.href; + + const records = response.records ?? response._embedded?.records ?? []; + + return { + records, + next: async (): Promise => { + if (nextUrl === undefined || nextUrl.length === 0) { + return emptyTransactionPage(); + } + return this.#getTransactionPage(nextUrl); + }, + }; + } + + #toAccountResponse(account: HorizonAccountJson): Horizon.AccountResponse { + const accountId = account.account_id ?? account.id; + const { sequence } = account; + + return Object.assign(account, { + accountId(): string | undefined { + return accountId; + }, + sequenceNumber(): string | undefined { + return sequence; + }, + }) as Horizon.AccountResponse; + } + + async #requestJson(pathOrUrl: string): Promise { + const url = pathOrUrl.startsWith('http') + ? pathOrUrl + : `${this.#baseUrl}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`; + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + const body = await response.text(); + const data = parseJsonBody(body); + + if (response.status === 404) { + throw new HorizonNotFoundError(`Horizon resource not found: ${url}`, { + cause: data, + }); + } + + if (!response.ok) { + throw new Error(`Horizon request failed with status ${response.status}`, { + cause: data, + }); + } + + return data as TResponse; + } +} + +/** + * Encodes query parameters without relying on URLSearchParams, which is not guaranteed in SES. + * + * @param params - Query parameters. + * @returns Encoded query string. + */ +function encodeQuery( + params: Record, +): string { + return Object.entries(params) + .filter(([, value]) => value !== undefined) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, + ) + .join('&'); +} + +/** + * Parses a JSON response body. + * + * @param body - Response text. + * @returns Parsed JSON, or null for empty responses. + */ +function parseJsonBody(body: string): unknown { + if (body.length === 0) { + return null; + } + return JSON.parse(body); +} + +/** + * Builds an empty Horizon transaction page. + * + * @returns Empty transaction page. + */ +function emptyTransactionPage(): HorizonTransactionPage { + return { + records: [], + next: async () => emptyTransactionPage(), + }; +} diff --git a/packages/snap/src/services/network/MultiCall.ts b/packages/snap/src/services/network/MultiCall.ts index e7392296..5744bd14 100644 --- a/packages/snap/src/services/network/MultiCall.ts +++ b/packages/snap/src/services/network/MultiCall.ts @@ -36,10 +36,16 @@ export enum StellarRouterContract { } export type StellarRouterParams = { - rpcClient: rpc.Server; + rpcClient: SimulateTransactionClient; simulationAccount: string; }; +export type SimulateTransactionClient = { + simulateTransaction: ( + tx: Transaction, + ) => Promise; +}; + export class InvocationV0 { contract: Address | string; @@ -76,7 +82,7 @@ export class InvocationV1 { } export class MultiCall { - readonly #rpcClient: rpc.Server; + readonly #rpcClient: SimulateTransactionClient; readonly #simulationAccount: string; @@ -87,7 +93,7 @@ export class MultiCall { simulationAccount = SIMULATION_ACCOUNT, routerContract = StellarRouterContract.V0, }: { - rpcClient: rpc.Server; + rpcClient: SimulateTransactionClient; simulationAccount?: string; routerContract?: StellarRouterContract; }) { @@ -170,7 +176,7 @@ export class MultiCall { const sim = await this.#rpcClient.simulateTransaction(tx); if (rpc.Api.isSimulationError(sim)) { - throw new Error(String(sim.error)); + throw new Error(sim.error); } const retval = sim.result?.retval; diff --git a/packages/snap/src/services/network/NetworkService.test.ts b/packages/snap/src/services/network/NetworkService.test.ts index 5bb341e5..4139709f 100644 --- a/packages/snap/src/services/network/NetworkService.test.ts +++ b/packages/snap/src/services/network/NetworkService.test.ts @@ -1,11 +1,10 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import type { Horizon } from '@stellar/stellar-sdk'; import { Account, Contract, - Horizon as StellarHorizon, Networks, nativeToScVal, - NotFoundError, rpc as StellarRpc, TransactionBuilder as StellarTransactionBuilder, } from '@stellar/stellar-sdk'; @@ -21,8 +20,10 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { HorizonClient, HorizonNotFoundError } from './HorizonClient'; import { MultiCall } from './MultiCall'; import { NetworkService } from './NetworkService'; +import { SorobanRpcClient } from './SorobanRpcClient'; import type { KnownCaip19ClassicAssetId, KnownCaip19Sep41AssetId, @@ -64,29 +65,29 @@ describe('NetworkService', () => { }); const getHorizonClientSpies = () => ({ - fetchBaseFeeSpy: jest.spyOn( - StellarHorizon.Server.prototype, - 'fetchBaseFee', - ), - loadAccountSpy: jest.spyOn(StellarHorizon.Server.prototype, 'loadAccount'), + fetchBaseFeeSpy: jest.spyOn(HorizonClient.prototype, 'fetchBaseFee'), + loadAccountSpy: jest.spyOn(HorizonClient.prototype, 'loadAccount'), + getAssetRecordsSpy: jest.spyOn(HorizonClient.prototype, 'getAssetRecords'), + getTransactionSpy: jest.spyOn(HorizonClient.prototype, 'getTransaction'), + getTransactionsSpy: jest.spyOn(HorizonClient.prototype, 'getTransactions'), }); const getRpcServerSpies = () => ({ pollTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, + SorobanRpcClient.prototype, 'pollTransaction', ), sendTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, + SorobanRpcClient.prototype, 'sendTransaction', ), - getAccountSpy: jest.spyOn(StellarRpc.Server.prototype, 'getAccount'), + getAccountSpy: jest.spyOn(SorobanRpcClient.prototype, 'getAccount'), getLedgerEntriesSpy: jest.spyOn( - StellarRpc.Server.prototype, + SorobanRpcClient.prototype, 'getLedgerEntries', ), simulateTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, + SorobanRpcClient.prototype, 'simulateTransaction', ), }); @@ -116,18 +117,8 @@ describe('NetworkService', () => { call: jest.Mock, ): jest.SpyInstance => { return jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - forAccount: jest.fn().mockReturnValue({ - order: jest.fn().mockReturnValue({ - cursor: jest.fn().mockReturnValue({ - limit: jest.fn().mockReturnValue({ - includeFailed: jest.fn().mockReturnValue({ call }), - }), - }), - }), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransactions') + .mockImplementation(async () => call()); }; const createMockInvokeHostFunctionTransaction = (accountId?: string) => { @@ -235,7 +226,7 @@ describe('NetworkService', () => { }); loadAccountSpy.mockResolvedValue( - account as unknown as StellarHorizon.AccountResponse, + account as unknown as Horizon.AccountResponse, ); const result = await networkService.loadOnChainAccount( @@ -251,7 +242,7 @@ describe('NetworkService', () => { it('throws AccountNotActivatedException when account is not found', async () => { const { loadAccountSpy } = getHorizonClientSpies(); - loadAccountSpy.mockRejectedValue(new NotFoundError('not found', {})); + loadAccountSpy.mockRejectedValue(new HorizonNotFoundError('not found')); await expect( networkService.loadOnChainAccount(testAddress, scope), @@ -279,7 +270,7 @@ describe('NetworkService', () => { assets: [], }); loadAccountSpy.mockResolvedValue( - account as unknown as StellarHorizon.AccountResponse, + account as unknown as Horizon.AccountResponse, ); const first = await networkService.loadOnChainAccountWithCache( @@ -311,12 +302,8 @@ describe('NetworkService', () => { assets: [], }); loadAccountSpy - .mockResolvedValueOnce( - accountV1 as unknown as StellarHorizon.AccountResponse, - ) - .mockResolvedValueOnce( - accountV2 as unknown as StellarHorizon.AccountResponse, - ); + .mockResolvedValueOnce(accountV1 as unknown as Horizon.AccountResponse) + .mockResolvedValueOnce(accountV2 as unknown as Horizon.AccountResponse); await networkService.loadOnChainAccountWithCache(testAddress, scope); await Promise.resolve(); @@ -352,13 +339,13 @@ describe('NetworkService', () => { createMockAccountWithBalances(addrA, '10', { nativeBalance: 1, assets: [], - }) as unknown as StellarHorizon.AccountResponse, + }) as unknown as Horizon.AccountResponse, ) .mockResolvedValueOnce( createMockAccountWithBalances(addrB, '20', { nativeBalance: 1, assets: [], - }) as unknown as StellarHorizon.AccountResponse, + }) as unknown as Horizon.AccountResponse, ); const result = await networkService.loadOnChainAccountsSafe( @@ -379,12 +366,12 @@ describe('NetworkService', () => { it('maps failures to null, preserves order, and logs a warning', async () => { const { loadAccountSpy } = getHorizonClientSpies(); loadAccountSpy - .mockRejectedValueOnce(new NotFoundError('not found', {})) + .mockRejectedValueOnce(new HorizonNotFoundError('not found')) .mockResolvedValueOnce( createMockAccountWithBalances(addrA, '1', { nativeBalance: 1, assets: [], - }) as unknown as StellarHorizon.AccountResponse, + }) as unknown as Horizon.AccountResponse, ); const result = await networkService.loadOnChainAccountsSafe( @@ -490,12 +477,8 @@ describe('NetworkService', () => { ], }); const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getAssetRecords') + .mockImplementation(async () => call()); const result = await networkService.getClassicAssetData( classicAssetId, @@ -515,12 +498,8 @@ describe('NetworkService', () => { it('throws NetworkServiceException when Horizon returns no rows', async () => { const call = jest.fn().mockResolvedValue({ records: [] }); const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getAssetRecords') + .mockImplementation(async () => call()); await expect( networkService.getClassicAssetData(classicAssetId, scope), @@ -540,12 +519,8 @@ describe('NetworkService', () => { ], }); const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getAssetRecords') + .mockImplementation(async () => call()); await expect( networkService.getClassicAssetData(classicAssetId, scope), @@ -557,12 +532,8 @@ describe('NetworkService', () => { it('wraps unexpected Horizon errors in NetworkServiceException', async () => { const call = jest.fn().mockRejectedValue(new Error('Horizon outage')); const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getAssetRecords') + .mockImplementation(async () => call()); await expect( networkService.getClassicAssetData(classicAssetId, scope), @@ -635,14 +606,8 @@ describe('NetworkService', () => { describe('getHorizonTransactionInclusionStatus', () => { it('returns pending when Horizon returns NotFoundError', async () => { const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ - call: jest - .fn() - .mockRejectedValue(new NotFoundError('not found', {})), - }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockRejectedValue(new HorizonNotFoundError('not found')); const result = await networkService.getHorizonTransactionInclusionStatus( testTransactionHash, @@ -656,10 +621,8 @@ describe('NetworkService', () => { it('returns success when Horizon record is successful', async () => { const call = jest.fn().mockResolvedValue({ successful: true }); const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ call }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockImplementation(async () => call()); const result = await networkService.getHorizonTransactionInclusionStatus( testTransactionHash, @@ -674,10 +637,8 @@ describe('NetworkService', () => { it('returns failed when Horizon record is not successful', async () => { const call = jest.fn().mockResolvedValue({ successful: false }); const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ call }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockImplementation(async () => call()); const result = await networkService.getHorizonTransactionInclusionStatus( testTransactionHash, @@ -691,10 +652,8 @@ describe('NetworkService', () => { it('throws NetworkServiceException when Horizon responds with a non-404 error', async () => { const call = jest.fn().mockRejectedValue(new Error('timeout')); const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ call }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockImplementation(async () => call()); await expect( networkService.getHorizonTransactionInclusionStatus( @@ -716,10 +675,8 @@ describe('NetworkService', () => { }); const call = jest.fn().mockResolvedValue(horizonRecord); const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ call }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockImplementation(async () => call()); const result = await networkService.getTransaction(tx.id, scope); @@ -733,12 +690,10 @@ describe('NetworkService', () => { it('throws TransactionNotFoundException when record is not found', async () => { const call = jest .fn() - .mockRejectedValue(new NotFoundError('not found', {})); + .mockRejectedValue(new HorizonNotFoundError('not found')); const transactionsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - transaction: jest.fn().mockReturnValue({ call }), - } as never); + .spyOn(HorizonClient.prototype, 'getTransaction') + .mockImplementation(async () => call()); await expect( networkService.getTransaction(testTransactionHash, scope), diff --git a/packages/snap/src/services/network/NetworkService.ts b/packages/snap/src/services/network/NetworkService.ts index e0417503..479a9492 100644 --- a/packages/snap/src/services/network/NetworkService.ts +++ b/packages/snap/src/services/network/NetworkService.ts @@ -1,11 +1,6 @@ import { parseCaipAssetType } from '@metamask/utils'; -import { - Address, - Contract, - Horizon as StellarHorizon, - NotFoundError, - rpc, -} from '@stellar/stellar-sdk'; +import type { Horizon } from '@stellar/stellar-sdk'; +import { Address, Contract, rpc } from '@stellar/stellar-sdk'; import type { AssetDataResponse } from './api'; import { KnownRpcError } from './api'; @@ -18,12 +13,14 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { HorizonClient, HorizonNotFoundError } from './HorizonClient'; import { InvocationV1, MultiCall, SIMULATION_ACCOUNT, StellarRouterContract, } from './MultiCall'; +import { SorobanRpcClient } from './SorobanRpcClient'; import { baseInclusionFee, isAccountNotFoundError, @@ -70,12 +67,9 @@ export class NetworkService { readonly #cache: ICache; - readonly #horizonClientMap = new Map< - KnownCaip2ChainId, - StellarHorizon.Server - >(); + readonly #horizonClientMap = new Map(); - readonly #rpcClientMap = new Map(); + readonly #rpcClientMap = new Map(); constructor({ logger, @@ -88,21 +82,19 @@ export class NetworkService { this.#cache = cache; } - #getHorizonClient(scope: KnownCaip2ChainId): StellarHorizon.Server { + #getHorizonClient(scope: KnownCaip2ChainId): HorizonClient { let client = this.#horizonClientMap.get(scope); if (!client) { - client = new StellarHorizon.Server( - this.#getNetworkConfig(scope).horizonUrl, - ); + client = new HorizonClient(this.#getNetworkConfig(scope).horizonUrl); this.#horizonClientMap.set(scope, client); } return client; } - #getRpcClient(scope: KnownCaip2ChainId): rpc.Server { + #getRpcClient(scope: KnownCaip2ChainId): SorobanRpcClient { let client = this.#rpcClientMap.get(scope); if (!client) { - client = new rpc.Server(this.#getNetworkConfig(scope).rpcUrl); + client = new SorobanRpcClient(this.#getNetworkConfig(scope).rpcUrl); this.#rpcClientMap.set(scope, client); } return client; @@ -171,13 +163,10 @@ export class NetworkService { ): Promise<'pending' | 'success' | 'failed'> { try { const client = this.#getHorizonClient(scope); - const record = await client - .transactions() - .transaction(transactionHash) - .call(); + const record = await client.getTransaction(transactionHash); return record.successful ? 'success' : 'failed'; } catch (error: unknown) { - if (error instanceof NotFoundError) { + if (error instanceof HorizonNotFoundError) { return 'pending'; } return this.#throwError({ @@ -439,11 +428,10 @@ export class NetworkService { const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( parseCaipAssetType(assetId).assetReference, ); - const assetData = await client - .assets() - .forCode(assetCode) - .forIssuer(assetIssuer) - .call(); + const assetData = await client.getAssetRecords({ + assetCode, + assetIssuer, + }); if ( !assetData || @@ -762,13 +750,10 @@ export class NetworkService { ): Promise { try { const client = this.#getHorizonClient(scope); - const result = await client - .transactions() - .transaction(transactionHash) - .call(); + const result = await client.getTransaction(transactionHash); return this.#toTransaction(result, scope); } catch (error: unknown) { - if (error instanceof NotFoundError) { + if (error instanceof HorizonNotFoundError) { throw new TransactionNotFoundException(transactionHash, { cause: error, }); @@ -832,14 +817,13 @@ export class NetworkService { try { const client = this.#getHorizonClient(scope); - const initialTransactionsResponse = await client - .transactions() - .forAccount(accountAddress) - .order(order) - .cursor(lastScanToken ?? '') - .limit(pageSize) - .includeFailed(includeFailed) - .call(); + const initialTransactionsResponse = await client.getTransactions({ + accountAddress, + order, + cursor: lastScanToken ?? '', + limit: pageSize, + includeFailed, + }); let transactions = this.#toTransactions( initialTransactionsResponse.records, @@ -885,7 +869,7 @@ export class NetworkService { } #toTransactions( - transactions: StellarHorizon.ServerApi.TransactionRecord[], + transactions: Horizon.ServerApi.TransactionRecord[], scope: KnownCaip2ChainId, accountAddress: string, includeSelfTransactionsOnly: boolean, @@ -906,7 +890,7 @@ export class NetworkService { } #toTransaction( - horizonTransaction: StellarHorizon.ServerApi.TransactionRecord, + horizonTransaction: Horizon.ServerApi.TransactionRecord, scope: KnownCaip2ChainId, ): Transaction { return Transaction.fromHorizon({ diff --git a/packages/snap/src/services/network/SorobanRpcClient.test.ts b/packages/snap/src/services/network/SorobanRpcClient.test.ts new file mode 100644 index 00000000..f60bd356 --- /dev/null +++ b/packages/snap/src/services/network/SorobanRpcClient.test.ts @@ -0,0 +1,123 @@ +import { SorobanRpcClient } from './SorobanRpcClient'; + +describe('SorobanRpcClient', () => { + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn< + ReturnType, + Parameters + >(); + + beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock; + }); + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + it('sends transactions through JSON-RPC fetch', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + status: 'PENDING', + hash: 'abc', + latestLedger: 123, + latestLedgerCloseTime: 456, + }, + }), + ); + const client = new SorobanRpcClient('https://rpc.example'); + + const result = await client.sendTransaction({ + toXDR: () => 'transaction-xdr', + } as never); + + expect(result).toStrictEqual({ + status: 'PENDING', + hash: 'abc', + latestLedger: 123, + latestLedgerCloseTime: 456, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://rpc.example', + expect.objectContaining({ + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: { + transaction: 'transaction-xdr', + }, + }), + }), + ); + }); + + it('throws JSON-RPC errors as Error objects with code and data', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32603, + message: 'transaction failed with tx_bad_seq', + data: { result: 'tx_bad_seq' }, + }, + }), + ); + const client = new SorobanRpcClient('https://rpc.example'); + + await expect( + client.sendTransaction({ toXDR: () => 'transaction-xdr' } as never), + ).rejects.toMatchObject({ + name: 'SorobanJsonRpcError', + code: -32603, + message: 'transaction failed with tx_bad_seq', + data: { result: 'tx_bad_seq' }, + }); + }); + + it('returns not-found transaction polling responses without parsing XDR', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + status: 'NOT_FOUND', + latestLedger: 123, + latestLedgerCloseTime: 456, + oldestLedger: 100, + oldestLedgerCloseTime: 111, + }, + }), + ); + const client = new SorobanRpcClient('https://rpc.example'); + + const result = await client.getTransaction('abc'); + + expect(result).toStrictEqual({ + status: 'NOT_FOUND', + txHash: 'abc', + latestLedger: 123, + latestLedgerCloseTime: 456, + oldestLedger: 100, + oldestLedgerCloseTime: 111, + }); + }); +}); + +function jsonResponse(body: unknown, status: number = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body), + } as Response; +} diff --git a/packages/snap/src/services/network/SorobanRpcClient.ts b/packages/snap/src/services/network/SorobanRpcClient.ts new file mode 100644 index 00000000..826785c9 --- /dev/null +++ b/packages/snap/src/services/network/SorobanRpcClient.ts @@ -0,0 +1,468 @@ +/* eslint-disable @typescript-eslint/naming-convention -- JSON-RPC and SDK wire fields use XDR names */ +import { + Account, + Keypair, + rpc, + SorobanDataBuilder, + type FeeBumpTransaction, + type Transaction as StellarTransaction, + xdr, +} from '@stellar/stellar-sdk'; + +const JSON_RPC_VERSION = '2.0'; +const REQUEST_ID = 1; +const DEFAULT_GET_TRANSACTION_TIMEOUT = 30; + +type JsonRpcError = { + code?: number; + data?: unknown; + message?: string; +}; + +type JsonRpcResponse = { + error?: JsonRpcError; + result?: TResponse; +}; + +class SorobanJsonRpcError extends Error { + readonly code?: number; + + readonly data?: unknown; + + constructor(error: JsonRpcError) { + super(error.message ?? 'Soroban RPC error'); + this.name = 'SorobanJsonRpcError'; + this.code = error.code; + this.data = error.data; + } +} + +type RawLedgerEntryChange = { + after: string | null; + before: string | null; + key: string; + type: number; +}; + +type RawSimulateHostFunctionResult = { + auth?: string[]; + xdr?: string; +}; + +type RawSimulateTransactionResponse = { + error?: string; + events?: string[]; + id: string; + latestLedger: number; + minResourceFee?: string; + results?: RawSimulateHostFunctionResult[]; + restorePreamble?: { + minResourceFee: string; + transactionData: string; + }; + stateChanges?: RawLedgerEntryChange[]; + transactionData?: string; +}; + +type RawSendTransactionResponse = rpc.Api.RawSendTransactionResponse & { + diagnosticEventsXdr?: string[]; + errorResultXdr?: string; +}; + +type RawGetTransactionResponse = rpc.Api.RawGetTransactionResponse; + +export type SorobanRpcPollOptions = { + attempts?: number; + sleepStrategy?: (attempt: number) => number; +}; + +/** + * Small Snap-safe Soroban JSON-RPC client using the platform `fetch` endowment directly. + */ +export class SorobanRpcClient { + readonly #rpcUrl: string; + + constructor(rpcUrl: string) { + this.#rpcUrl = rpcUrl; + } + + async getAccount(accountAddress: string): Promise { + const ledgerKey = xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(accountAddress).xdrPublicKey(), + }), + ); + + try { + const entry = await this.#getLedgerEntry(ledgerKey); + return new Account( + accountAddress, + entry.val.account().seqNum().toString(), + ); + } catch { + throw new Error(`Account not found: ${accountAddress}`); + } + } + + async getLedgerEntries( + ...keys: xdr.LedgerKey[] + ): Promise { + const result = await this.#post( + 'getLedgerEntries', + { + keys: keys.map((key) => key.toXDR('base64')), + }, + ); + + return parseRawLedgerEntries(result); + } + + async pollTransaction( + hash: string, + opts?: SorobanRpcPollOptions, + ): Promise { + const maxAttempts = + (opts?.attempts ?? 0) < 1 + ? DEFAULT_GET_TRANSACTION_TIMEOUT + : (opts?.attempts ?? DEFAULT_GET_TRANSACTION_TIMEOUT); + let foundInfo: rpc.Api.GetTransactionResponse | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + foundInfo = await this.getTransaction(hash); + if (foundInfo.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) { + return foundInfo; + } + await sleep((opts?.sleepStrategy ?? basicSleepStrategy)(attempt)); + } + + if (foundInfo === undefined) { + throw new Error(`Failed to poll transaction: ${hash}`); + } + return foundInfo; + } + + async getTransaction(hash: string): Promise { + const raw = await this.#post('getTransaction', { + hash, + }); + + const foundInfo = + raw.status === rpc.Api.GetTransactionStatus.NOT_FOUND + ? {} + : parseRawTransactionInfo(raw); + + return { + status: raw.status, + txHash: hash, + latestLedger: raw.latestLedger, + latestLedgerCloseTime: raw.latestLedgerCloseTime, + oldestLedger: raw.oldestLedger, + oldestLedgerCloseTime: raw.oldestLedgerCloseTime, + ...foundInfo, + } as rpc.Api.GetTransactionResponse; + } + + async sendTransaction( + transaction: FeeBumpTransaction | StellarTransaction, + ): Promise { + const result = await this.#post( + 'sendTransaction', + { + transaction: transaction.toXDR(), + }, + ); + + return parseRawSendTransaction(result); + } + + async simulateTransaction( + transaction: FeeBumpTransaction | StellarTransaction, + ): Promise { + const result = await this.#post( + 'simulateTransaction', + { + transaction: transaction.toXDR(), + }, + ); + + return parseRawSimulation(result); + } + + async #getLedgerEntry( + key: xdr.LedgerKey, + ): Promise { + const results = await this.getLedgerEntries(key); + if (results.entries.length !== 1 || results.entries[0] === undefined) { + throw new Error(`failed to find an entry for key ${key.toXDR('base64')}`); + } + return results.entries[0]; + } + + async #post(method: string, params?: unknown): Promise { + const response = await fetch(this.#rpcUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: JSON_RPC_VERSION, + id: REQUEST_ID, + method, + params, + }), + }); + const body = await response.text(); + const json = parseJsonBody(body) as JsonRpcResponse | null; + + if (!response.ok) { + throw new Error( + `Soroban RPC request failed with status ${response.status}`, + { + cause: json, + }, + ); + } + + if (json?.error !== undefined) { + throw new SorobanJsonRpcError(json.error); + } + + return json?.result as TResponse; + } +} + +/** + * Parses raw ledger entry rows into SDK XDR values. + * + * @param raw - Raw RPC ledger entry response. + * @returns Parsed ledger entry response. + */ +function parseRawLedgerEntries( + raw: rpc.Api.RawGetLedgerEntriesResponse, +): rpc.Api.GetLedgerEntriesResponse { + return { + latestLedger: raw.latestLedger, + entries: (raw.entries ?? []).map((entry) => { + if (!entry.key || !entry.xdr) { + throw new TypeError(`invalid ledger entry: ${JSON.stringify(entry)}`); + } + return { + lastModifiedLedgerSeq: entry.lastModifiedLedgerSeq, + key: xdr.LedgerKey.fromXDR(entry.key, 'base64'), + val: xdr.LedgerEntryData.fromXDR(entry.xdr, 'base64'), + ...(entry.liveUntilLedgerSeq === undefined + ? {} + : { liveUntilLedgerSeq: entry.liveUntilLedgerSeq }), + }; + }), + }; +} + +/** + * Parses a raw send transaction response into SDK XDR values. + * + * @param raw - Raw RPC send transaction response. + * @returns Parsed send transaction response. + */ +function parseRawSendTransaction( + raw: RawSendTransactionResponse, +): rpc.Api.SendTransactionResponse { + const { diagnosticEventsXdr, errorResultXdr, ...rest } = raw; + if (errorResultXdr !== undefined && errorResultXdr.length > 0) { + return { + ...rest, + ...(diagnosticEventsXdr !== undefined && diagnosticEventsXdr.length > 0 + ? { + diagnosticEvents: diagnosticEventsXdr.map((event) => + xdr.DiagnosticEvent.fromXDR(event, 'base64'), + ), + } + : {}), + errorResult: xdr.TransactionResult.fromXDR(errorResultXdr, 'base64'), + }; + } + return { ...rest }; +} + +/** + * Parses a raw simulation response into SDK XDR values. + * + * @param raw - Raw RPC simulation response. + * @returns Parsed simulation response. + */ +function parseRawSimulation( + raw: RawSimulateTransactionResponse, +): rpc.Api.SimulateTransactionResponse { + const base = { + _parsed: true, + id: raw.id, + latestLedger: raw.latestLedger, + events: + raw.events?.map((event) => + xdr.DiagnosticEvent.fromXDR(event, 'base64'), + ) ?? [], + }; + + if (typeof raw.error === 'string') { + return { + ...base, + error: raw.error, + }; + } + + const success = { + ...base, + transactionData: new SorobanDataBuilder(raw.transactionData), + minResourceFee: raw.minResourceFee ?? '0', + ...((raw.results?.length ?? 0) > 0 + ? { + result: raw.results?.map((row) => ({ + auth: + row.auth?.map((entry) => + xdr.SorobanAuthorizationEntry.fromXDR(entry, 'base64'), + ) ?? [], + retval: + row.xdr === undefined || row.xdr.length === 0 + ? xdr.ScVal.scvVoid() + : xdr.ScVal.fromXDR(row.xdr, 'base64'), + }))[0], + } + : {}), + ...((raw.stateChanges?.length ?? 0) > 0 + ? { + stateChanges: raw.stateChanges?.map((entryChange) => ({ + type: entryChange.type, + key: xdr.LedgerKey.fromXDR(entryChange.key, 'base64'), + before: + entryChange.before === null + ? null + : xdr.LedgerEntry.fromXDR(entryChange.before, 'base64'), + after: + entryChange.after === null + ? null + : xdr.LedgerEntry.fromXDR(entryChange.after, 'base64'), + })), + } + : {}), + }; + + if ( + raw.restorePreamble === undefined || + raw.restorePreamble.transactionData.length === 0 + ) { + return success; + } + + return { + ...success, + restorePreamble: { + minResourceFee: raw.restorePreamble.minResourceFee, + transactionData: new SorobanDataBuilder( + raw.restorePreamble.transactionData, + ), + }, + } as rpc.Api.SimulateTransactionResponse; +} + +/** + * Parses raw transaction polling details into SDK XDR values. + * + * @param raw - Raw RPC transaction response. + * @returns Parsed transaction details. + */ +function parseRawTransactionInfo( + raw: RawGetTransactionResponse, +): + | Omit< + rpc.Api.GetFailedTransactionResponse, + | 'latestLedger' + | 'latestLedgerCloseTime' + | 'oldestLedger' + | 'oldestLedgerCloseTime' + | 'status' + | 'txHash' + > + | Omit< + rpc.Api.GetSuccessfulTransactionResponse, + | 'latestLedger' + | 'latestLedgerCloseTime' + | 'oldestLedger' + | 'oldestLedgerCloseTime' + | 'status' + | 'txHash' + > { + if ( + raw.envelopeXdr === undefined || + raw.resultXdr === undefined || + raw.resultMetaXdr === undefined + ) { + throw new TypeError('invalid getTransaction response missing XDR fields'); + } + + const resultMetaXdr = xdr.TransactionMeta.fromXDR( + raw.resultMetaXdr, + 'base64', + ); + return { + ledger: raw.ledger ?? 0, + createdAt: raw.createdAt ?? 0, + applicationOrder: raw.applicationOrder ?? 0, + feeBump: raw.feeBump ?? false, + envelopeXdr: xdr.TransactionEnvelope.fromXDR(raw.envelopeXdr, 'base64'), + resultXdr: xdr.TransactionResult.fromXDR(raw.resultXdr, 'base64'), + resultMetaXdr, + events: { + contractEventsXdr: + raw.events?.contractEventsXdr?.map((eventList) => + eventList.map((event) => xdr.ContractEvent.fromXDR(event, 'base64')), + ) ?? [], + transactionEventsXdr: + raw.events?.transactionEventsXdr?.map((event) => + xdr.TransactionEvent.fromXDR(event, 'base64'), + ) ?? [], + }, + ...(raw.diagnosticEventsXdr === undefined + ? {} + : { + diagnosticEventsXdr: raw.diagnosticEventsXdr.map((event) => + xdr.DiagnosticEvent.fromXDR(event, 'base64'), + ), + }), + }; +} + +/** + * Parses a JSON response body. + * + * @param body - Response text. + * @returns Parsed JSON, or null for empty responses. + */ +function parseJsonBody(body: string): unknown { + if (body.length === 0) { + return null; + } + return JSON.parse(body); +} + +/** + * Default RPC polling sleep strategy. + * + * @param _attempt - Poll attempt number. + * @returns Milliseconds to sleep. + */ +function basicSleepStrategy(_attempt: number): number { + return 1000; +} + +/** + * Sleeps for the requested duration. + * + * @param milliseconds - Duration in milliseconds. + * @returns Promise that resolves after the timeout. + */ +async function sleep(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/packages/snap/src/services/network/utils.ts b/packages/snap/src/services/network/utils.ts index 9852cdc3..05578a60 100644 --- a/packages/snap/src/services/network/utils.ts +++ b/packages/snap/src/services/network/utils.ts @@ -1,7 +1,8 @@ import { ensureError } from '@metamask/utils'; -import { Networks, NotFoundError } from '@stellar/stellar-sdk'; +import { Networks } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { HorizonNotFoundError } from './HorizonClient'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { BASE_FEE } from '../../constants'; @@ -83,7 +84,7 @@ export function isAccountNotFoundError( error: unknown, accountAddress: string, ): boolean { - if (error instanceof NotFoundError) { + if (error instanceof HorizonNotFoundError) { return true; } return ensureError(error).message === `Account not found: ${accountAddress}`; diff --git a/packages/snap/src/services/transaction/OperationMapper.ts b/packages/snap/src/services/transaction/OperationMapper.ts index c583e69f..03a23f47 100644 --- a/packages/snap/src/services/transaction/OperationMapper.ts +++ b/packages/snap/src/services/transaction/OperationMapper.ts @@ -1,5 +1,5 @@ import type { Json } from '@metamask/utils'; -import type { Asset, Operation } from '@stellar/stellar-sdk'; +import type { Asset, OperationRecord } from '@stellar/stellar-sdk'; import { LiquidityPoolAsset, LiquidityPoolId, xdr } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -109,7 +109,17 @@ function accountAuthFlagsMaskToText(flags: number): string[] { /* eslint-enable no-bitwise */ /** - * Maps Stellar {@link Operation} values to plain JSON-friendly objects for signing UX. + * Checks whether an SDK-decoded optional field is set. + * + * @param value - Optional decoded field value. + * @returns Whether the value is neither null nor undefined. + */ +function isPresent(value: Value | null | undefined): value is Value { + return value !== undefined && value !== null; +} + +/** + * Maps Stellar operation records to plain JSON-friendly objects for signing UX. */ export class OperationMapper { /** @@ -145,7 +155,7 @@ export class OperationMapper { * @returns Serializable operation summary. */ mapOperation( - operation: Operation, + operation: OperationRecord, index: number, transactionSource: string, ): ReadableOperationJson { @@ -166,7 +176,7 @@ export class OperationMapper { }; } - #mapSorobanPlaceholder(operation: Operation): ReadableOperationField[] { + #mapSorobanPlaceholder(operation: OperationRecord): ReadableOperationField[] { if (operation.type === StellarOperationType.InvokeHostFunction) { const hostOp = operation; const rows: ReadableOperationField[] = []; @@ -227,7 +237,7 @@ export class OperationMapper { ]; } - #mapClassicParams(operation: Operation): ReadableOperationField[] { + #mapClassicParams(operation: OperationRecord): ReadableOperationField[] { /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- enum cases mirror SDK `operation.type` literals */ switch (operation.type) { case StellarOperationType.Payment: { @@ -352,12 +362,12 @@ export class OperationMapper { case StellarOperationType.SetOptions: { const setOptions = operation; const rows: ReadableOperationField[] = []; - if (setOptions.inflationDest !== undefined) { + if (isPresent(setOptions.inflationDest)) { rows.push( this.#field('inflationDest', setOptions.inflationDest, 'address'), ); } - if (setOptions.clearFlags !== undefined) { + if (isPresent(setOptions.clearFlags)) { rows.push( this.#field( 'clearFlags', @@ -366,7 +376,7 @@ export class OperationMapper { ), ); } - if (setOptions.setFlags !== undefined) { + if (isPresent(setOptions.setFlags)) { rows.push( this.#field( 'setFlags', @@ -375,30 +385,30 @@ export class OperationMapper { ), ); } - if (setOptions.masterWeight !== undefined) { + if (isPresent(setOptions.masterWeight)) { rows.push( this.#field('masterWeight', setOptions.masterWeight, 'number'), ); } - if (setOptions.lowThreshold !== undefined) { + if (isPresent(setOptions.lowThreshold)) { rows.push( this.#field('lowThreshold', setOptions.lowThreshold, 'number'), ); } - if (setOptions.medThreshold !== undefined) { + if (isPresent(setOptions.medThreshold)) { rows.push( this.#field('medThreshold', setOptions.medThreshold, 'number'), ); } - if (setOptions.highThreshold !== undefined) { + if (isPresent(setOptions.highThreshold)) { rows.push( this.#field('highThreshold', setOptions.highThreshold, 'number'), ); } - if (setOptions.homeDomain !== undefined) { + if (isPresent(setOptions.homeDomain)) { rows.push(this.#field('homeDomain', setOptions.homeDomain, 'text')); } - if ('signer' in setOptions && setOptions.signer !== undefined) { + if ('signer' in setOptions && isPresent(setOptions.signer)) { // SDK Signer is a union of disjoint interfaces; cast to Record for key-based branching. const signer = setOptions.signer as unknown as Record< string, @@ -510,7 +520,13 @@ export class OperationMapper { } case StellarOperationType.EndSponsoringFutureReserves: return []; - case StellarOperationType.RevokeSponsorship: + case StellarOperationType.RevokeAccountSponsorship: + case StellarOperationType.RevokeTrustlineSponsorship: + case StellarOperationType.RevokeOfferSponsorship: + case StellarOperationType.RevokeDataSponsorship: + case StellarOperationType.RevokeClaimableBalanceSponsorship: + case StellarOperationType.RevokeLiquidityPoolSponsorship: + case StellarOperationType.RevokeSignerSponsorship: return this.#mapRevokeSponsorship(operation); case StellarOperationType.Clawback: { const clawback = operation; @@ -600,7 +616,7 @@ export class OperationMapper { } } - #mapRevokeSponsorship(operation: Operation): ReadableOperationField[] { + #mapRevokeSponsorship(operation: OperationRecord): ReadableOperationField[] { if ('seller' in operation && 'offerId' in operation) { const revokeOffer = operation as { seller: string; diff --git a/packages/snap/src/services/transaction/Transaction.ts b/packages/snap/src/services/transaction/Transaction.ts index 1b15a3d9..c6f33a09 100644 --- a/packages/snap/src/services/transaction/Transaction.ts +++ b/packages/snap/src/services/transaction/Transaction.ts @@ -1,7 +1,7 @@ import { TransactionStatus } from '@metamask/keyring-api'; import type { Transaction as StellarTransaction, - Operation, + OperationRecord, Horizon, } from '@stellar/stellar-sdk'; import { @@ -336,7 +336,7 @@ export class Transaction { * * @returns The operations. */ - get transactionOperations(): Operation[] { + get transactionOperations(): OperationRecord[] { const raw = this.getRaw(); if (raw instanceof FeeBumpTransaction) { return raw.innerTransaction.operations; diff --git a/packages/snap/src/services/transaction/TransactionBuilder.ts b/packages/snap/src/services/transaction/TransactionBuilder.ts index 15c1d1f4..48ee4d72 100644 --- a/packages/snap/src/services/transaction/TransactionBuilder.ts +++ b/packages/snap/src/services/transaction/TransactionBuilder.ts @@ -1,5 +1,5 @@ import { parseCaipAssetType } from '@metamask/utils'; -import type { xdr, OperationOptions } from '@stellar/stellar-sdk'; +import type { xdr } from '@stellar/stellar-sdk'; import { Account, Address, @@ -75,7 +75,7 @@ export class TransactionBuilder { assertAssetScopeMatch(assetId, scope); try { - const operationOpt: OperationOptions.ChangeTrust = { + const operationOpt: Parameters[0] = { asset: caip19ToStellarAsset(assetId), }; if (limit !== undefined) { diff --git a/packages/snap/src/services/transaction/TransactionSimulator.test.ts b/packages/snap/src/services/transaction/TransactionSimulator.test.ts index 98b26d85..31b3bca0 100644 --- a/packages/snap/src/services/transaction/TransactionSimulator.test.ts +++ b/packages/snap/src/services/transaction/TransactionSimulator.test.ts @@ -1,4 +1,3 @@ -import type { Operation } from '@stellar/stellar-sdk'; import { Account, Asset, @@ -7,6 +6,7 @@ import { nativeToScVal, Networks, Operation as StellarOperation, + type OperationRecord, TransactionBuilder, } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -35,17 +35,17 @@ import { import { KnownCaip2ChainId } from '../../api'; import { ACCOUNT_REQUIRES_MEMO, MEMO_REQUIRED_KEY } from '../../constants'; import { caip2ChainIdToNetwork } from '../network/utils'; +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, + type BuildMockTransactionOptions, +} from './__mocks__/transaction.fixtures'; import { createMockAccountWithBalances, horizonSource, type MockAccountWithBalancesData, } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; -import { - buildMockClassicTransaction, - buildMockInvokeHostFunctionTransaction, - type BuildMockTransactionOptions, -} from './__mocks__/transaction.fixtures'; import { generateStellarAddress, getTestWallet, @@ -1512,7 +1512,9 @@ describe('TransactionSimulator', () => { const [invokeOp] = sorobanTx.transactionOperations; jest .spyOn(sorobanTx, 'transactionOperations', 'get') - .mockReturnValue([{ ...invokeOp, source: otherSource } as Operation]); + .mockReturnValue([ + { ...invokeOp, source: otherSource } as OperationRecord, + ]); expect(() => simulator.simulate(sorobanTx, loaded)).toThrow( TransactionValidationException, diff --git a/packages/snap/src/services/transaction/TransactionSimulator.ts b/packages/snap/src/services/transaction/TransactionSimulator.ts index 6b59c06e..1d20dd3c 100644 --- a/packages/snap/src/services/transaction/TransactionSimulator.ts +++ b/packages/snap/src/services/transaction/TransactionSimulator.ts @@ -1,4 +1,4 @@ -import type { Operation } from '@stellar/stellar-sdk'; +import type { Operation, OperationRecord } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { StellarOperationType } from './api'; @@ -170,7 +170,7 @@ export class TransactionSimulator { } #preflightValidation( - ops: Operation[], + ops: OperationRecord[], account: OnChainAccount, transaction: Transaction, options?: TransactionSimulatorOptions, @@ -259,7 +259,7 @@ export class TransactionSimulator { } #assertSupportedOP( - op: Operation, + op: OperationRecord, supportedOPTypeSet: Set, ): asserts op is SupportedOPType { const operationType = this.#supportedOperationType(op); @@ -268,7 +268,7 @@ export class TransactionSimulator { } } - #assertExpectedOP(op: Operation, types: Set): void { + #assertExpectedOP(op: OperationRecord, types: Set): void { const operationType = this.#supportedOperationType(op); if (operationType === null) { throw new UnsupportedOperationTypeException(op.type); @@ -281,8 +281,8 @@ export class TransactionSimulator { } #assertOPLength( - ops: Operation[], - ): asserts ops is [Operation, ...Operation[]] { + ops: OperationRecord[], + ): asserts ops is [OperationRecord, ...OperationRecord[]] { if (ops.length === 0) { throw new TransactionValidationException( `Transaction must have at least one operation`, @@ -321,7 +321,7 @@ export class TransactionSimulator { state: SimulationState; txSource: string; scope: KnownCaip2ChainId; - operations: readonly Operation[]; + operations: readonly OperationRecord[]; transaction: Transaction; }): void { const { op, opIndex, state, txSource, scope, operations, transaction } = @@ -409,7 +409,7 @@ export class TransactionSimulator { return operationType; } - #supportedOperationType(op: Operation): SupportedOperations | null { + #supportedOperationType(op: OperationRecord): SupportedOperations | null { if (op.type === SupportedOperations.Payment) { return SupportedOperations.Payment; } diff --git a/packages/snap/src/services/transaction/api.ts b/packages/snap/src/services/transaction/api.ts index b23892bf..973a8256 100644 --- a/packages/snap/src/services/transaction/api.ts +++ b/packages/snap/src/services/transaction/api.ts @@ -46,7 +46,14 @@ export enum StellarOperationType { PathPaymentStrictSend = 'pathPaymentStrictSend', Payment = 'payment', RestoreFootprint = 'restoreFootprint', + RevokeAccountSponsorship = 'revokeAccountSponsorship', + RevokeClaimableBalanceSponsorship = 'revokeClaimableBalanceSponsorship', + RevokeDataSponsorship = 'revokeDataSponsorship', + RevokeLiquidityPoolSponsorship = 'revokeLiquidityPoolSponsorship', + RevokeOfferSponsorship = 'revokeOfferSponsorship', + RevokeSignerSponsorship = 'revokeSignerSponsorship', RevokeSponsorship = 'revokeSponsorship', + RevokeTrustlineSponsorship = 'revokeTrustlineSponsorship', SetOptions = 'setOptions', SetTrustLineFlags = 'setTrustLineFlags', } diff --git a/packages/snap/src/services/transaction/simulation/api.ts b/packages/snap/src/services/transaction/simulation/api.ts index 345a320e..13182637 100644 --- a/packages/snap/src/services/transaction/simulation/api.ts +++ b/packages/snap/src/services/transaction/simulation/api.ts @@ -1,4 +1,4 @@ -import type { Operation } from '@stellar/stellar-sdk'; +import type { OperationRecord } from '@stellar/stellar-sdk'; import type { KnownCaip19ClassicAssetId, @@ -68,8 +68,8 @@ export type ValidateContext = ApplyContext & { export type OperationSimulator = { validate( ctx: ValidateContext, - op: Operation, - allOperations?: readonly Operation[], + op: OperationRecord, + allOperations?: readonly OperationRecord[], ): void; - apply(ctx: ApplyContext, op: Operation): void; + apply(ctx: ApplyContext, op: OperationRecord): void; }; diff --git a/packages/snap/src/services/transaction/simulation/simulators.ts b/packages/snap/src/services/transaction/simulation/simulators.ts index 20e03557..33971c5c 100644 --- a/packages/snap/src/services/transaction/simulation/simulators.ts +++ b/packages/snap/src/services/transaction/simulation/simulators.ts @@ -38,10 +38,13 @@ import { assertMemoWhenDestinationRequires } from '../utils'; import { isSep41TransferInvoke, parseSep41TransferInvoke } from '../xdrParser'; type ClassicAssetId = KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; - -type PathPaymentOP = +type PaymentOperation = Operation.Payment; +type PathPaymentOperation = | Operation.PathPaymentStrictReceive | Operation.PathPaymentStrictSend; +type CreateAccountOperation = Operation.CreateAccount; +type ChangeTrustOperation = Operation.ChangeTrust; +type InvokeHostFunctionOperation = Operation.InvokeHostFunction; /** * Converts a Stellar SDK native or classic asset to this Snap's CAIP asset id form. @@ -212,7 +215,7 @@ function applyCredit(params: { } export class PaymentOPSimulator implements OperationSimulator { - validate(ctx: ValidateContext, op: Operation.Payment): void { + validate(ctx: ValidateContext, op: PaymentOperation): void { const payment = op; const { opIndex } = ctx; const { assetId, payAmt, source, dest, sourceId, destId } = @@ -262,7 +265,7 @@ export class PaymentOPSimulator implements OperationSimulator { }); } - apply(ctx: ApplyContext, op: Operation.Payment): void { + apply(ctx: ApplyContext, op: PaymentOperation): void { const { assetId, payAmt, source, dest } = this.#getContextData(ctx, op); applyDebit({ account: source, assetId, amount: payAmt }); @@ -271,7 +274,7 @@ export class PaymentOPSimulator implements OperationSimulator { #getContextData( ctx: ApplyContext, - op: Operation.Payment, + op: PaymentOperation, ): { sourceId: string; destId: string; @@ -291,7 +294,7 @@ export class PaymentOPSimulator implements OperationSimulator { return { sourceId, destId, payAmt, assetId, source, dest }; } - #paymentDestinationAccountId(op: Operation.Payment): string { + #paymentDestinationAccountId(op: PaymentOperation): string { const { destination } = op; if (typeof destination === 'string') { return destination; @@ -303,7 +306,7 @@ export class PaymentOPSimulator implements OperationSimulator { } export class PathPaymentOPSimulator implements OperationSimulator { - validate(ctx: ValidateContext, op: PathPaymentOP): void { + validate(ctx: ValidateContext, op: PathPaymentOperation): void { const { source, sourceId, sendAssetId, sendAmount } = this.#sourceData( ctx, op, @@ -351,7 +354,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { }); } - apply(ctx: ApplyContext, op: PathPaymentOP): void { + apply(ctx: ApplyContext, op: PathPaymentOperation): void { const { source, sendAssetId, sendAmount } = this.#sourceData(ctx, op); const { dest, destAssetId, destAmount } = this.#destinationData(ctx, op); @@ -369,7 +372,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { #sourceData( ctx: ApplyContext, - op: PathPaymentOP, + op: PathPaymentOperation, ): { source: AccountState; sourceId: string; @@ -395,7 +398,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { #destinationData( ctx: ApplyContext, - op: PathPaymentOP, + op: PathPaymentOperation, ): { dest: AccountState; destId: string; @@ -421,7 +424,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { } export class CreateAccountOPSimulator implements OperationSimulator { - validate(ctx: ValidateContext, op: Operation.CreateAccount): void { + validate(ctx: ValidateContext, op: CreateAccountOperation): void { const { state, opIndex, scope } = ctx; if (typeof op.destination !== 'string' || op.destination.length === 0) { throw new TransactionValidationException( @@ -456,7 +459,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { } } - apply(ctx: ApplyContext, op: Operation.CreateAccount): void { + apply(ctx: ApplyContext, op: CreateAccountOperation): void { const { state } = ctx; const { source, destId, startingBalance } = this.#getContextData(ctx, op); @@ -475,7 +478,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { #getContextData( ctx: ApplyContext, - op: Operation.CreateAccount, + op: CreateAccountOperation, ): { source: AccountState; destId: string; startingBalance: BigNumber } { const { txSource, state } = ctx; const funderId = effectiveSource(op, txSource); @@ -487,7 +490,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { } export class ChangeTrustOPSimulator implements OperationSimulator { - validate(ctx: ValidateContext, op: Operation.ChangeTrust): void { + validate(ctx: ValidateContext, op: ChangeTrustOperation): void { const { opIndex } = ctx; if ( op.limit === undefined || @@ -542,7 +545,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { } } - apply(ctx: ApplyContext, op: Operation.ChangeTrust): void { + apply(ctx: ApplyContext, op: ChangeTrustOperation): void { const { source, assetId, trustlineLimit } = this.#getContextData(ctx, op); const sourceTrustline = source.trustlines.get(assetId); @@ -583,7 +586,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { #getContextData( ctx: ApplyContext, - op: Operation.ChangeTrust, + op: ChangeTrustOperation, ): { source: AccountState; sourceId: string; @@ -599,12 +602,14 @@ export class ChangeTrustOPSimulator implements OperationSimulator { `ChangeTrust line must be Stellar SAC Asset or Stellar Classic Asset, ${asset.constructor.name} is not supported`, ); } + const issuer = asset.getIssuer(); + if (issuer === undefined) { + throw new InvalidTrustlineException( + `ChangeTrust line must be Stellar Classic Asset with an issuer`, + ); + } - const assetId = toCaip19ClassicAssetId( - scope, - asset.getCode(), - asset.getIssuer(), - ); + const assetId = toCaip19ClassicAssetId(scope, asset.getCode(), issuer); // Operation limit is in human-readable form; convert to stroops like Horizon balances. const limit = new BigNumber(op.limit); @@ -617,7 +622,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { } export class InvokeHostFunctionOPSimulator implements OperationSimulator { - validate(ctx: ValidateContext, op: Operation.InvokeHostFunction): void { + validate(ctx: ValidateContext, op: InvokeHostFunctionOperation): void { const { txSource, state, scope } = ctx; const sourceId = effectiveSource(op, txSource); // Contract transaction should always be sourced from the user wallet account @@ -662,7 +667,7 @@ export class InvokeHostFunctionOPSimulator implements OperationSimulator { } } - apply(_ctx: ApplyContext, _op: Operation.InvokeHostFunction): void { + apply(_ctx: ApplyContext, _op: InvokeHostFunctionOperation): void { // InvokeHostFunction is a single operation transaction, // hence we don't need to apply any balance or trustline effects for Soroban invoke during simulation. } diff --git a/packages/snap/src/services/transaction/simulation/utils.ts b/packages/snap/src/services/transaction/simulation/utils.ts index 16723cba..13fe9e24 100644 --- a/packages/snap/src/services/transaction/simulation/utils.ts +++ b/packages/snap/src/services/transaction/simulation/utils.ts @@ -1,4 +1,4 @@ -import type { Operation } from '@stellar/stellar-sdk'; +import type { OperationRecord } from '@stellar/stellar-sdk'; import { TransactionValidationException } from '../exceptions'; import type { AccountState, SimulationState } from './api'; @@ -12,7 +12,7 @@ import { calculateSpendableBalance } from '../../on-chain-account/utils'; * @param txSource - The transaction source. * @returns Effective source account public key. */ -export function effectiveSource(op: Operation, txSource: string): string { +export function effectiveSource(op: OperationRecord, txSource: string): string { return op.source ?? txSource; } diff --git a/packages/snap/src/services/transaction/utils.ts b/packages/snap/src/services/transaction/utils.ts index abd6357b..e5f4747c 100644 --- a/packages/snap/src/services/transaction/utils.ts +++ b/packages/snap/src/services/transaction/utils.ts @@ -3,7 +3,7 @@ import { type Transaction as KeyringTransaction, } from '@metamask/keyring-api'; import { parseCaipAssetType } from '@metamask/utils'; -import type { Operation } from '@stellar/stellar-sdk'; +import type { Operation, OperationRecord } from '@stellar/stellar-sdk'; import { Asset } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -282,7 +282,7 @@ export function parseExpirationMaxTime( * @returns Whether the operation is an invoke host function operation. */ export function isInvokeHostFunctionOperation( - operation: Operation | undefined, + operation: OperationRecord | undefined, ): operation is Operation.InvokeHostFunction { return ( operation !== undefined && @@ -297,7 +297,7 @@ export function isInvokeHostFunctionOperation( * @returns Whether the operation is a payment operation. */ export function isPaymentOperation( - operation: Operation, + operation: OperationRecord, ): operation is Operation.Payment { return operation.type === StellarOperationType.Payment; } @@ -309,7 +309,7 @@ export function isPaymentOperation( * @returns Whether the operation is a path payment operation. */ export function isPathPaymentOperation( - operation: Operation, + operation: OperationRecord, ): operation is | Operation.PathPaymentStrictSend | Operation.PathPaymentStrictReceive { @@ -503,7 +503,7 @@ export function isDustPaymentTransaction( * @returns Whether the operation credits `accountAddress`. */ export function isReceiveOperation( - operation: Operation, + operation: OperationRecord, accountAddress: string, ): operation is | Operation.Payment diff --git a/packages/snap/src/services/transaction/xdrParser.ts b/packages/snap/src/services/transaction/xdrParser.ts index fbbf76a2..fce8cbbf 100644 --- a/packages/snap/src/services/transaction/xdrParser.ts +++ b/packages/snap/src/services/transaction/xdrParser.ts @@ -1,4 +1,3 @@ -import type { Operation } from '@stellar/stellar-sdk'; import { Asset, StrKey, @@ -6,6 +5,7 @@ import { scValToNative, Address, } from '@stellar/stellar-sdk'; +import type { Operation } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { XdrParseException } from './exceptions'; @@ -279,11 +279,11 @@ export function xdrAssetToCaip19( case 'assetTypeCreditAlphanum12': { try { const stellarAsset = Asset.fromOperation(asset); - return toCaip19ClassicAssetId( - scope, - stellarAsset.getCode(), - stellarAsset.getIssuer(), - ); + const issuer = stellarAsset.getIssuer(); + if (issuer === undefined) { + return undefined; + } + return toCaip19ClassicAssetId(scope, stellarAsset.getCode(), issuer); } catch { return undefined; } diff --git a/packages/snap/src/utils/caip.ts b/packages/snap/src/utils/caip.ts index b19e1178..6f4b450c 100644 --- a/packages/snap/src/utils/caip.ts +++ b/packages/snap/src/utils/caip.ts @@ -182,5 +182,9 @@ export function stellarAssetToCaip19( if (asset.isNative()) { return getSlip44AssetId(scope); } - return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer()); + const issuer = asset.getIssuer(); + if (issuer === undefined) { + throw new Error(`Invalid classic asset without issuer`); + } + return toCaip19ClassicAssetId(scope, asset.getCode(), issuer); } diff --git a/yarn.lock b/yarn.lock index 11678a37..ea5833c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2252,7 +2252,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.1.0" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.11.0" - "@stellar/stellar-sdk": "npm:^15.0.1" + "@stellar/stellar-sdk": "npm:^16.0.1" "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.3.1" @@ -2333,7 +2333,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.7": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -2342,6 +2342,13 @@ __metadata: languageName: node linkType: hard +"@noble/ed25519@npm:^3.1.0": + version: 3.1.0 + resolution: "@noble/ed25519@npm:3.1.0" + checksum: 10/79f6fd3b1e1011387de9d2244bbd8268a0bc1d2b303af6f92875c97326ebeb7675f2d2342ca148237a57372162bbc8e1345e698a7c06099c87d64c3f78b0249c + languageName: node + linkType: hard + "@noble/hashes@npm:1.3.2": version: 1.3.2 resolution: "@noble/hashes@npm:1.3.2" @@ -2363,6 +2370,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^2.2.0": + version: 2.2.0 + resolution: "@noble/hashes@npm:2.2.0" + checksum: 10/b1b78bedc2a01394be047429f3d888905015fe8a09f1b7e43e0b5736b54133df62f73dcc73ede43af38e96e86156afb45b86973fdeaa95d9f0880333c3fc0907 + languageName: node + linkType: hard + "@noble/hashes@npm:~1.3.2": version: 1.3.3 resolution: "@noble/hashes@npm:1.3.3" @@ -2685,43 +2699,32 @@ __metadata: languageName: node linkType: hard -"@stellar/js-xdr@npm:^4.0.0": +"@stellar/js-xdr@npm:4.0.0": version: 4.0.0 resolution: "@stellar/js-xdr@npm:4.0.0" checksum: 10/d300c723e18f9d99c666499f8744a31981390ef8d780b2a321e019cbd23436a2de5b3d999e7af5e9ed334b14801497c0a19ad86ab052aff420415f2d89785a7b languageName: node linkType: hard -"@stellar/stellar-base@npm:^15.0.0": - version: 15.0.0 - resolution: "@stellar/stellar-base@npm:15.0.0" +"@stellar/stellar-sdk@npm:^16.0.1": + version: 16.0.1 + resolution: "@stellar/stellar-sdk@npm:16.0.1" dependencies: - "@noble/curves": "npm:^1.9.7" - "@stellar/js-xdr": "npm:^4.0.0" + "@noble/ed25519": "npm:^3.1.0" + "@noble/hashes": "npm:^2.2.0" + "@stellar/js-xdr": "npm:4.0.0" + axios: "npm:1.16.1" base32.js: "npm:^0.1.0" - bignumber.js: "npm:^9.3.1" + bignumber.js: "npm:^11.1.1" buffer: "npm:^6.0.3" - sha.js: "npm:^2.4.12" - checksum: 10/7a0a485395a27082adba4ab601311a25933eced065c81abbbec6d1bdf184ef9f5d42c3393fdf55f00f061da672e2511820436261c4dcc4b4eed4f3766a0b9c6c - languageName: node - linkType: hard - -"@stellar/stellar-sdk@npm:^15.0.1": - version: 15.0.1 - resolution: "@stellar/stellar-sdk@npm:15.0.1" - dependencies: - "@stellar/stellar-base": "npm:^15.0.0" - axios: "npm:1.14.0" - bignumber.js: "npm:^9.3.1" commander: "npm:^14.0.3" - eventsource: "npm:^2.0.2" + eventsource: "npm:^4.1.0" feaxios: "npm:^0.0.23" - randombytes: "npm:^2.1.0" - toml: "npm:^3.0.0" - urijs: "npm:^1.19.11" + smol-toml: "npm:^1.6.1" + uint8array-extras: "npm:^1.5.0" bin: stellar-js: bin/stellar-js - checksum: 10/cda3e99dd06a1978b00ccf6c8fc29d3c4b331663b70e0f7f1ac3b7c2bd239b78570b4ff5089a957ee5ee4f8fd3655ec89d155e5c9c1d86228159eef34b361772 + checksum: 10/1095f2ad9dec8a26aeb19f50554169f76cbd084b1f8be172196374b6e9f2e2f617bb2c05f0db5db76fe64824834f8152a6110198433dc774fa40e794879a84c5 languageName: node linkType: hard @@ -3930,14 +3933,15 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.14.0": - version: 1.14.0 - resolution: "axios@npm:1.14.0" +"axios@npm:1.16.1": + version: 1.16.1 + resolution: "axios@npm:1.16.1" dependencies: - follow-redirects: "npm:^1.15.11" + follow-redirects: "npm:^1.16.0" form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10/c3444e9e3da1714916e4ddd7cda05bb41a5d5d80e3e27b099a116439684c63f2280c88503d1acd65841698b63af0b542b4d5780454e28fd0aed2d783ef90943e + checksum: 10/9b6218cf96321cfbbf8f160658d695367114bcf4fb62492bdc1ccd647f184b5c71ae400e5ecaaf41079bc561de2ecbaf1fec63f398b3ec53389beff7694df64c languageName: node linkType: hard @@ -4152,6 +4156,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^11.1.1": + version: 11.1.4 + resolution: "bignumber.js@npm:11.1.4" + checksum: 10/3c5badd975dbe30ae11933818725dd9115dc402c56754566c9c0844c41d7d14627ab3d676414e90ab6567423161ffee720695bf0cd32e3975e78eeb1965bcc4b + languageName: node + linkType: hard + "bignumber.js@npm:^9.1.2, bignumber.js@npm:^9.3.1": version: 9.3.1 resolution: "bignumber.js@npm:9.3.1" @@ -6062,10 +6073,19 @@ __metadata: languageName: node linkType: hard -"eventsource@npm:^2.0.2": - version: 2.0.2 - resolution: "eventsource@npm:2.0.2" - checksum: 10/e1c4c3664cebf9efdd55c90818ef847099f298bf521768d479cf22d8a681e666b3042de85327711ba6a8414ac6a04c70d2aeb4f405bba8239a8c36e06a019374 +"eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 10/6aa03b4d6e3450935690fd9cca6e47b9877287c9419dba9705b85a73e741c7dfbc22b4ebeca25adf05c9549e33c4491e3ca71f80ddfac585e469b7da91f76e20 + languageName: node + linkType: hard + +"eventsource@npm:^4.1.0": + version: 4.1.0 + resolution: "eventsource@npm:4.1.0" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10/d23b1c2a56a1dd7344a151ca57d70ebc624972150395c1c82110bda1d2918487260e8ec76c1c79677406184ecb1847509a675d4ee5367925ef77f03c9cae594d languageName: node linkType: hard @@ -6401,13 +6421,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" +"follow-redirects@npm:^1.16.0": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: debug: optional: true - checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba + checksum: 10/3fbe3d80b3b544c22705d837aa5d4a0d07a740d913534a2620b0a004c610af4148e3b58723536dd099aaa1c9d3a155964bde9665d6e5cb331460809a1fc572fd languageName: node linkType: hard @@ -7000,7 +7020,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -10215,7 +10235,7 @@ __metadata: languageName: node linkType: hard -"sha.js@npm:^2.4.0, sha.js@npm:^2.4.11, sha.js@npm:^2.4.12, sha.js@npm:^2.4.8": +"sha.js@npm:^2.4.0, sha.js@npm:^2.4.11, sha.js@npm:^2.4.8": version: 2.4.12 resolution: "sha.js@npm:2.4.12" dependencies: @@ -10354,6 +10374,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:^1.6.1": + version: 1.7.0 + resolution: "smol-toml@npm:1.7.0" + checksum: 10/b99829e4d9b357f5841eca9d9bcedbb5cb8421645d698b9d41a49cd97d039d747b92a9882efe640d7cc9a9fd1da8862ce7352e5d59ae097f8a9d3a0a56792e8d + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -10908,13 +10935,6 @@ __metadata: languageName: node linkType: hard -"toml@npm:^3.0.0": - version: 3.0.0 - resolution: "toml@npm:3.0.0" - checksum: 10/cfef0966868d552bd02e741f30945a611f70841b7cddb07ea2b17441fe32543985bc0a7c0dcf7971af26fcaf8a17712a485d911f46bfe28644536e9a71a2bd09 - languageName: node - linkType: hard - "totalist@npm:^3.0.0": version: 3.0.1 resolution: "totalist@npm:3.0.1" @@ -11155,6 +11175,13 @@ __metadata: languageName: node linkType: hard +"uint8array-extras@npm:^1.5.0": + version: 1.5.0 + resolution: "uint8array-extras@npm:1.5.0" + checksum: 10/94fd56a2dda6a7445f5176f301f491814c87757d38e4b3c932299ab54d69ec504830e5d5c18ffa20cf694a69a210315be8b4a2c9952c6334da817ea2d2e1dce0 + languageName: node + linkType: hard + "undici-types@npm:~6.19.2": version: 6.19.8 resolution: "undici-types@npm:6.19.8" @@ -11302,13 +11329,6 @@ __metadata: languageName: node linkType: hard -"urijs@npm:^1.19.11": - version: 1.19.11 - resolution: "urijs@npm:1.19.11" - checksum: 10/2aa5547b53c37ebee03a8ad70feae1638a37cc4c7e543abbffb14fc86b17f84f303d08e45c501441410c025bab22aa84673c97604b7b2619967f1dd49f69931f - languageName: node - linkType: hard - "url@npm:^0.11.1": version: 0.11.1 resolution: "url@npm:0.11.1"