diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 9e145871f5..a4c5fe7bc8 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -726,6 +726,12 @@ export const getTokenPrices = async ( // release. A default silently opts new callers into v2 and defeats the // kill switch. useV2: boolean, + // Cancels the request when the caller no longer needs the answer (e.g. the + // confirmation price snapshot's terminal-status deadline). A true network + // abort on the v1 path; the v2 request runs in the background service + // worker across a message boundary the signal cannot cross, so there it + // only skips a not-yet-sent request and rejects a no-longer-wanted result. + signal?: AbortSignal, ): Promise => { // NOTE: API does not accept LP IDs or custom tokens const filteredTokens = tokens.filter((tokenId) => { @@ -758,6 +764,10 @@ export const getTokenPrices = async ( return {}; } + if (signal?.aborted) { + throw new DOMException("token-prices request aborted", "AbortError"); + } + // Query lives in the path so callBackendV2 signs the JWT's methodAndPath // over the server's full request-target (path + query) — see #2879. const { status, body } = await fetchBackendV2({ @@ -766,6 +776,12 @@ export const getTokenPrices = async ( body: requestBody, }); + // The background request cannot be cancelled mid-flight (see `signal` + // param doc); reject a result nobody wants instead of returning it. + if (signal?.aborted) { + throw new DOMException("token-prices request aborted", "AbortError"); + } + // Mirror getDiscoverData: a 200 without a `data` payload is still a // failure — returning undefined would violate the Promise // contract (the caller's try/catch only handles throws, not bad returns). @@ -789,6 +805,7 @@ export const getTokenPrices = async ( "Content-Type": "application/json", }, body: requestBody, + signal, }; const response = await fetch(url.href, options); const parsedResponse = (await response.json()) as { data: ApiTokenPrices }; diff --git a/extension/src/helpers/confirmationPriceSnapshot.test.ts b/extension/src/helpers/confirmationPriceSnapshot.test.ts new file mode 100644 index 0000000000..d27182f974 --- /dev/null +++ b/extension/src/helpers/confirmationPriceSnapshot.test.ts @@ -0,0 +1,203 @@ +import * as ApiInternal from "@shared/api/internal"; +import { ApiTokenPrices } from "@shared/api/types"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { + PriceFreshness, + PriceSource, + startConfirmationPriceSnapshot, +} from "./confirmationPriceSnapshot"; + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("startConfirmationPriceSnapshot", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("uses the freshly fetched prices once the fetch has settled (confirmation_fetch)", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: true, + cachedDisplayPrices: { native: { currentPrice: "0.1" } }, + }); + + await flushMicrotasks(); + + expect(handle.resolve()).toEqual({ + pricesById: { native: { currentPrice: "0.5" } }, + freshness: PriceFreshness.CONFIRMATION_FETCH, + source: PriceSource.TOKEN_PRICES_V2, + }); + }); + + it("falls back wholesale to the display cache when the fetch only covers some of the requested ids", async () => { + // A 200 that omits the swap destination (e.g. a non-held token + // /token-prices has no entry for) - a partial result isn't trusted even + // for the ids it does cover. + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native", "USDC:ISSUER"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: true, + cachedDisplayPrices: { native: { currentPrice: "0.1" } }, + }); + + await flushMicrotasks(); + + expect(handle.resolve()).toEqual({ + pricesById: { native: { currentPrice: "0.1" } }, + freshness: PriceFreshness.CACHED_DISPLAY, + source: PriceSource.TOKEN_PRICES_V2, + }); + }); + + it("falls back to the cached display prices when the fetch hasn't settled yet (cached_display)", () => { + // Never resolves within this test — resolve() is called before any await. + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementation(() => new Promise(() => {})); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: false, + cachedDisplayPrices: { native: { currentPrice: "0.1" } }, + }); + + expect(handle.resolve()).toEqual({ + pricesById: { native: { currentPrice: "0.1" } }, + freshness: PriceFreshness.CACHED_DISPLAY, + source: PriceSource.TOKEN_PRICES_V1, + }); + }); + + it("falls back to the cached display prices when the fetch rejects", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockRejectedValue(new Error("network down")); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: true, + cachedDisplayPrices: { native: { currentPrice: "0.1" } }, + }); + + await flushMicrotasks(); + + // A rejected fetch degrades exactly like a still-pending one: coverage + // takes priority over freshness, and the degradation is visible via + // `cached_display` rather than reported as unpriced legs. + expect(handle.resolve()).toEqual({ + pricesById: { native: { currentPrice: "0.1" } }, + freshness: PriceFreshness.CACHED_DISPLAY, + source: PriceSource.TOKEN_PRICES_V2, + }); + }); + + it("degrades to a null snapshot (not a throw) when the fetch rejects and no display price is cached", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockRejectedValue(new Error("network down")); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: true, + cachedDisplayPrices: null, + }); + + await flushMicrotasks(); + + expect(handle.resolve()).toEqual({ + pricesById: null, + freshness: PriceFreshness.CACHED_DISPLAY, + source: PriceSource.TOKEN_PRICES_V2, + }); + }); + + it("aborts a still-pending fetch at resolve() so the request cannot outlive the flow", () => { + let capturedSignal: AbortSignal | undefined; + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementation((_tokens, _network, _useV2, signal) => { + capturedSignal = signal; + return new Promise(() => {}); + }); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: false, + cachedDisplayPrices: null, + }); + + expect(capturedSignal?.aborted).toBe(false); + handle.resolve(); + expect(capturedSignal?.aborted).toBe(true); + }); + + it("cancel() aborts the fetch without producing a snapshot (pre-submission failure)", () => { + let capturedSignal: AbortSignal | undefined; + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementation((_tokens, _network, _useV2, signal) => { + capturedSignal = signal; + return new Promise(() => {}); + }); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: false, + cachedDisplayPrices: null, + }); + + handle.cancel(); + expect(capturedSignal?.aborted).toBe(true); + // Idempotent, and safe to combine with a later resolve(). + handle.cancel(); + expect(handle.resolve().freshness).toBe(PriceFreshness.CACHED_DISPLAY); + }); + + it("never consults a late-arriving result after resolve() already ran", async () => { + let resolveFetch!: (value: ApiTokenPrices) => void; + jest.spyOn(ApiInternal, "getTokenPrices").mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const handle = startConfirmationPriceSnapshot({ + canonicalIds: ["native"], + networkDetails: TESTNET_NETWORK_DETAILS, + useV2: true, + cachedDisplayPrices: { native: { currentPrice: "0.2" } }, + }); + + // Not settled yet — this is the snapshot the terminal event uses. + const frozen = handle.resolve(); + expect(frozen.freshness).toBe(PriceFreshness.CACHED_DISPLAY); + + // The fetch resolves only after the snapshot was already frozen. + resolveFetch({ native: { currentPrice: "999" } }); + await flushMicrotasks(); + + // Calling resolve() again would now see it as settled — proving the + // *first* frozen snapshot (already returned above) never changes. + expect(frozen).toEqual({ + pricesById: { native: { currentPrice: "0.2" } }, + freshness: PriceFreshness.CACHED_DISPLAY, + source: PriceSource.TOKEN_PRICES_V2, + }); + }); +}); diff --git a/extension/src/helpers/confirmationPriceSnapshot.ts b/extension/src/helpers/confirmationPriceSnapshot.ts new file mode 100644 index 0000000000..239f87d474 --- /dev/null +++ b/extension/src/helpers/confirmationPriceSnapshot.ts @@ -0,0 +1,121 @@ +import { getTokenPrices } from "@shared/api/internal"; +import { ApiTokenPrices } from "@shared/api/types"; +import { NetworkDetails } from "@shared/constants/stellar"; + +export enum PriceSource { + TOKEN_PRICES_V1 = "token_prices_v1", + TOKEN_PRICES_V2 = "token_prices_v2", +} + +export enum PriceFreshness { + CONFIRMATION_FETCH = "confirmation_fetch", + CACHED_DISPLAY = "cached_display", +} + +export interface ConfirmationPriceSnapshot { + /** Prices by canonical id. `null` when no snapshot could be produced. */ + pricesById: ApiTokenPrices | null; + freshness: PriceFreshness; + source: PriceSource; +} + +export interface ConfirmationSnapshotHandle { + /** + * Freezes and returns the snapshot for a terminal event. Call exactly once, + * at terminal status. If the fetch already succeeded, uses its result + * (`confirmation_fetch`); otherwise — still pending, rejected, or cancelled + * — the fetch is aborted and its result, even if it lands later, is never + * consulted again, and this falls back to the prices already cached for the + * on-screen display estimate (`cached_display`). + */ + resolve(): ConfirmationPriceSnapshot; + /** + * Aborts the fetch and discards its result without producing a snapshot. + * For a confirmation attempt that ends before submission — no terminal + * event will consume the snapshot, so the request is cancelled immediately. + * Idempotent, and safe after `resolve()`. + */ + cancel(): void; +} + +/** + * Issues ONE price fetch covering every leg's canonical id, started at + * confirmation and never blocking signing/submission — callers do not await + * this. `cachedDisplayPrices` is the price map already held for the on-screen + * fiat estimate, captured by the caller at this same moment: it must reflect + * "the price already shown to the user for this transaction", not whatever + * the cache holds later when `resolve()` is called. + * + * Cancellation is a real network abort on the v1 endpoint (a direct fetch). + * The v2 endpoint runs in the background service worker across a message + * boundary the AbortSignal cannot cross, so there cancellation is best-effort: + * the request is skipped if already aborted, and a result that arrives after + * abort is discarded even though the HTTP itself ran to completion. + */ +export const startConfirmationPriceSnapshot = ({ + canonicalIds, + networkDetails, + useV2, + cachedDisplayPrices, +}: { + canonicalIds: string[]; + networkDetails: NetworkDetails; + useV2: boolean; + cachedDisplayPrices: ApiTokenPrices | null; +}): ConfirmationSnapshotHandle => { + const source: PriceSource = useV2 + ? PriceSource.TOKEN_PRICES_V2 + : PriceSource.TOKEN_PRICES_V1; + + const controller = new AbortController(); + let succeeded = false; + let fetchedPrices: ApiTokenPrices | null = null; + + // Never an unhandled rejection: a failed fetch degrades to cached_display + // exactly like one that's merely still pending at resolve() time. + getTokenPrices(canonicalIds, networkDetails, useV2, controller.signal) + .then((result) => { + // A result landing after abort is discarded, never consulted. + if (!controller.signal.aborted) { + fetchedPrices = result; + succeeded = true; + } + }) + .catch(() => { + // Rejected (network error, non-2xx, or aborted): fall back to the + // display-cache price at resolve() time rather than reporting the legs + // unpriced — coverage takes priority over freshness. + succeeded = false; + }); + + return { + resolve: () => { + // A 200 can still omit a requested id (e.g. a non-held destination + // token /token-prices has no entry for). A partial result isn't + // trustworthy enough to use even for the ids it does cover, so it's + // treated the same as no result at all: fall back to the display + // cache wholesale rather than merging. + const isComplete = + succeeded && canonicalIds.every((id) => fetchedPrices?.[id] != null); + if (isComplete) { + return { + pricesById: fetchedPrices, + freshness: PriceFreshness.CONFIRMATION_FETCH, + source, + }; + } + // Pending, rejected, incomplete, or cancelled: abort so the request + // cannot outlive the flow that needed it, and close on the display + // cache. + controller.abort(); + return { + pricesById: cachedDisplayPrices, + freshness: PriceFreshness.CACHED_DISPLAY, + source, + }; + }, + cancel: () => { + controller.abort(); + }, + }; +}; diff --git a/extension/src/helpers/metrics.test.ts b/extension/src/helpers/metrics.test.ts index 5b3d087af5..460d700ea3 100644 --- a/extension/src/helpers/metrics.test.ts +++ b/extension/src/helpers/metrics.test.ts @@ -145,8 +145,8 @@ describe("buildCommonContext (four-bucket property model)", () => { ); }); - it("stamps schema_version '2'", () => { - expect(buildCommonContext({} as never).schema_version).toBe("2"); + it("stamps schema_version '3'", () => { + expect(buildCommonContext({} as never).schema_version).toBe("3"); }); it("emits the reshaped event-level bucket", () => { @@ -212,7 +212,7 @@ describe("buildCommonContext (four-bucket property model)", () => { expect(ctx).not.toHaveProperty("account_funded"); expect(ctx).not.toHaveProperty("is_hardware_account"); // non-account context is still present - expect(ctx).toMatchObject({ schema_version: "2", network: "TESTNET" }); + expect(ctx).toMatchObject({ schema_version: "3", network: "TESTNET" }); expect(ctx.surface).toBeDefined(); }); @@ -524,7 +524,7 @@ describe("emitScreenViewed (screen.viewed consolidation)", () => { expect(body).toMatchObject({ screen_name: "send_payment_amount", flow: "send", - schema_version: "2", + schema_version: "3", }); // surface comes from the Slice-A common context (getSurface()). expect(body.surface).toBeDefined(); @@ -610,7 +610,7 @@ describe("app.opened", () => { expect(call![1]).toMatchObject({ connection_type: "wifi", effective_type: "4g", - schema_version: "2", + schema_version: "3", }); expect(call![1].surface).toBeDefined(); }); diff --git a/extension/src/helpers/metrics.ts b/extension/src/helpers/metrics.ts index 2cf852ed5a..cf43ac9c1c 100644 --- a/extension/src/helpers/metrics.ts +++ b/extension/src/helpers/metrics.ts @@ -121,8 +121,13 @@ let hasInitialized = false; */ const AMPLITUDE_FLUSH_INTERVAL_MS = 500; -/** Schema generation marker for the new cross-platform property model. */ -export const SCHEMA_VERSION = "2"; +/** + * Schema generation marker for the cross-platform property model. Bumped to + * "3" for the swap/send USD volume telemetry: without a bump, an event with + * no `amount_usd` is ambiguous between a pre-change client and a post-change + * client that genuinely had no price. + */ +export const SCHEMA_VERSION = "3"; /** Maps the internal account type to the RFC's wire value for `account_type`. */ const ACCOUNT_TYPE_WIRE: Record = { diff --git a/extension/src/helpers/transactionResult.test.ts b/extension/src/helpers/transactionResult.test.ts new file mode 100644 index 0000000000..9bd1545eef --- /dev/null +++ b/extension/src/helpers/transactionResult.test.ts @@ -0,0 +1,133 @@ +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, + xdr, +} from "stellar-sdk"; + +import { + findPathPaymentStrictSendIndex, + getSettledPathPaymentStrictSendAmount, +} from "./transactionResult"; + +/** Builds a TransactionResult XDR (base64) whose op at `index` settled a + * pathPaymentStrictSend for `stroops`, padded with plain payment successes. */ +const buildPathPaymentSuccessResultXdr = ( + stroops: string, + index: number, + opCount: number, +): string => { + const destination = xdr.PublicKey.publicKeyTypeEd25519( + Keypair.random().rawPublicKey(), + ); + const simple = new xdr.SimplePaymentResult({ + destination, + asset: Asset.native().toXdrObject(), + amount: BigInt(stroops), + }); + const success = new xdr.PathPaymentStrictSendResultSuccess({ + offers: [], + last: simple, + }); + const pathPaymentOpResult = xdr.OperationResult.opInner( + xdr.OperationResultTr.pathPaymentStrictSend( + xdr.PathPaymentStrictSendResult.pathPaymentStrictSendSuccess(success), + ), + ); + const plainPaymentOpResult = xdr.OperationResult.opInner( + xdr.OperationResultTr.payment(xdr.PaymentResult.paymentSuccess()), + ); + + const results = Array.from({ length: opCount }, (_, i) => + i === index ? pathPaymentOpResult : plainPaymentOpResult, + ); + + const txResult = new xdr.TransactionResult({ + feeCharged: BigInt("100"), + result: xdr.TransactionResultResult.txSuccess(results), + ext: xdr.TransactionResultExt.v0(), + }); + return txResult.toXdr("base64"); +}; + +describe("getSettledPathPaymentStrictSendAmount", () => { + it("reads the settled destination amount in whole units", () => { + const resultXdr = buildPathPaymentSuccessResultXdr("50000000", 0, 1); + const amount = getSettledPathPaymentStrictSendAmount(resultXdr, 0); + expect(amount?.toString()).toBe("5"); + }); + + it("selects the operation by index when preceded by other operations (e.g. a changeTrust)", () => { + const resultXdr = buildPathPaymentSuccessResultXdr("12345000", 1, 2); + expect( + getSettledPathPaymentStrictSendAmount(resultXdr, 1)?.toString(), + ).toBe("1.2345"); + // The other operation at index 0 is a plain payment success, not a path + // payment — reading it as one fails cleanly rather than misreading data. + expect(getSettledPathPaymentStrictSendAmount(resultXdr, 0)).toBeNull(); + }); + + it("returns null for a negative or missing operation index", () => { + const resultXdr = buildPathPaymentSuccessResultXdr("50000000", 0, 1); + expect(getSettledPathPaymentStrictSendAmount(resultXdr, -1)).toBeNull(); + expect(getSettledPathPaymentStrictSendAmount(resultXdr, 5)).toBeNull(); + }); + + it("returns null (never throws) for garbage XDR", () => { + expect( + getSettledPathPaymentStrictSendAmount("not-valid-xdr", 0), + ).toBeNull(); + expect( + getSettledPathPaymentStrictSendAmount(undefined as unknown as string, 0), + ).toBeNull(); + }); + + it("returns null when the operation didn't succeed", () => { + const failedResult = xdr.OperationResult.opInner( + xdr.OperationResultTr.pathPaymentStrictSend( + xdr.PathPaymentStrictSendResult.pathPaymentStrictSendUnderfunded(), + ), + ); + const txResult = new xdr.TransactionResult({ + feeCharged: BigInt("100"), + result: xdr.TransactionResultResult.txFailed([failedResult]), + ext: xdr.TransactionResultExt.v0(), + }); + expect( + getSettledPathPaymentStrictSendAmount(txResult.toXdr("base64"), 0), + ).toBeNull(); + }); +}); + +describe("findPathPaymentStrictSendIndex", () => { + it("finds the operation's position, after an optional changeTrust", () => { + const kp = Keypair.random(); + const account = new Account(kp.publicKey(), "0"); + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.changeTrust({ + asset: new Asset("USDC", Keypair.random().publicKey()), + }), + ) + .addOperation( + Operation.pathPaymentStrictSend({ + sendAsset: Asset.native(), + sendAmount: "5", + destination: kp.publicKey(), + destAsset: new Asset("USDC", Keypair.random().publicKey()), + destMin: "1", + path: [], + }), + ) + .setTimeout(30) + .build(); + + expect(findPathPaymentStrictSendIndex(tx)).toBe(1); + }); +}); diff --git a/extension/src/helpers/transactionResult.ts b/extension/src/helpers/transactionResult.ts new file mode 100644 index 0000000000..2fda3eac6a --- /dev/null +++ b/extension/src/helpers/transactionResult.ts @@ -0,0 +1,80 @@ +import BigNumber from "bignumber.js"; +import { Transaction, FeeBumpTransaction, xdr } from "stellar-sdk"; + +import { stroopToXlm } from "helpers/stellar"; + +/** + * Locates a `pathPaymentStrictSend` operation's position within a built + * transaction. Operations and their per-operation results are always + * positionally aligned, so this index is what selects the right entry out of + * the decoded transaction result. + */ +export const findPathPaymentStrictSendIndex = ( + transaction: Transaction | FeeBumpTransaction, +): number => { + const operations = + "innerTransaction" in transaction + ? transaction.innerTransaction.operations + : transaction.operations; + return operations.findIndex((op) => op.type === "pathPaymentStrictSend"); +}; + +/** + * Reads the *settled* destination amount of a `pathPaymentStrictSend` + * operation from a transaction's Horizon result XDR — never the quote. + * Returns whole units (classic/native assets are always 7 decimals, and + * swap legs are always native or classic). + * + * Returns `null` for anything that isn't a clean success read: the + * transaction/operation didn't succeed, the operation at `operationIndex` + * wasn't a pathPaymentStrictSend, or the XDR couldn't be parsed. Callers + * treat `null` as `to_amount_usd_status: "error"` — this never throws out + * of a telemetry path. + */ +export const getSettledPathPaymentStrictSendAmount = ( + resultXdr: string, + operationIndex: number, +): BigNumber | null => { + if (operationIndex < 0) { + return null; + } + try { + const txResult = xdr.TransactionResult.fromXdr(resultXdr, "base64"); + const innerResult = txResult.result; + + // A fee-bump transaction's per-operation results live one level down, in + // the inner transaction's own result. + const innerTxResult = + innerResult.type === "txFeeBumpInnerSuccess" || + innerResult.type === "txFeeBumpInnerFailed" + ? innerResult.innerResultPair.result.result + : innerResult; + + if ( + innerTxResult.type !== "txSuccess" && + innerTxResult.type !== "txFailed" + ) { + return null; + } + const opResults = innerTxResult.results; + + const opResult = opResults[operationIndex]; + if (!opResult || opResult.type !== "opInner") { + return null; + } + if (opResult.tr.type !== "pathPaymentStrictSend") { + return null; + } + + const pathResult = opResult.tr.pathPaymentStrictSendResult; + if (pathResult.type !== "pathPaymentStrictSendSuccess") { + return null; + } + const success = pathResult.success; + const stroops = success.last.amount; + + return stroopToXlm(new BigNumber(stroops.toString())); + } catch { + return null; + } +}; diff --git a/extension/src/helpers/usdVolume.test.ts b/extension/src/helpers/usdVolume.test.ts new file mode 100644 index 0000000000..0e741d14b8 --- /dev/null +++ b/extension/src/helpers/usdVolume.test.ts @@ -0,0 +1,359 @@ +import BigNumber from "bignumber.js"; +import { Asset, Keypair, Networks } from "stellar-sdk"; + +import { BalanceMap } from "@shared/api/types/backend-api"; +import { ErrorMessage } from "@shared/api/types"; +import { + AssetKind, + classifyAssetIdentity, + computeExecutionSlippagePct, + computeUsdSlippagePct, + deriveLegUsd, + FailureCategory, + getFailureCategory, + LegUsdStatus, + roundHalfUp2dp, +} from "./usdVolume"; + +describe("roundHalfUp2dp", () => { + it("rounds half up at the 2dp boundary", () => { + expect(roundHalfUp2dp("1.005")).toBe(1.01); + expect(roundHalfUp2dp("1.004")).toBe(1.0); + expect(roundHalfUp2dp(1.115)).toBe(1.12); + }); + + it("never floors like the extension's existing roundUsdValue", () => { + // roundUsdValue would report 0.00 here (Math.floor bias); half-up must not. + expect(roundHalfUp2dp("0.009")).toBe(0.01); + }); + + it("handles negative values (slippage can be negative)", () => { + expect(roundHalfUp2dp("-12.345")).toBe(-12.35); + }); +}); + +describe("deriveLegUsd", () => { + it("is no_price when no price is held for the asset", () => { + expect(deriveLegUsd("10", undefined)).toEqual({ + status: LegUsdStatus.NO_PRICE, + }); + expect(deriveLegUsd("10", null as unknown as undefined)).toEqual({ + status: LegUsdStatus.NO_PRICE, + }); + }); + + it("is ok and rounds half-up when a price is held", () => { + const result = deriveLegUsd("10.5", "1.999"); + if (result.status !== LegUsdStatus.OK) { + throw new Error(`expected ok, got ${result.status}`); + } + expect(result.value).toBe(20.99); // 10.5 * 1.999 = 20.9895 -> half-up -> 20.99 + expect(result.rate).toBe(1.999); + expect(result.unrounded.toString()).toBe("20.9895"); + }); + + it("never emits 0 for a missing price — that's no_price, not a real zero", () => { + const result = deriveLegUsd("0", undefined); + expect(result.status).toBe(LegUsdStatus.NO_PRICE); + expect("value" in result).toBe(false); + }); + + it("emits a real 0.00 for a genuine zero-value transfer when priced", () => { + const result = deriveLegUsd("0", "1.5"); + if (result.status !== LegUsdStatus.OK) { + throw new Error(`expected ok, got ${result.status}`); + } + expect(result.value).toBe(0); + }); + + it("is error when the derivation produces a non-finite result", () => { + expect(deriveLegUsd("not-a-number", "1.5").status).toBe(LegUsdStatus.ERROR); + expect(deriveLegUsd("10", "not-a-number").status).toBe(LegUsdStatus.ERROR); + }); +}); + +describe("computeUsdSlippagePct", () => { + it("is negative when the user received less USD value than they gave up", () => { + const pct = computeUsdSlippagePct( + new BigNumber("100"), + new BigNumber("99"), + ); + expect(pct).toBe(-1); + }); + + it("rounds only the final percentage, from unrounded inputs", () => { + const pct = computeUsdSlippagePct( + new BigNumber("33.333"), + new BigNumber("33.1"), + ); + // (33.1 - 33.333) / 33.333 * 100 = -0.699009... + expect(pct).toBe(-0.7); + }); + + it("is undefined when the source value is zero (no ratio)", () => { + expect( + computeUsdSlippagePct(new BigNumber(0), new BigNumber("5")), + ).toBeUndefined(); + }); +}); + +describe("computeExecutionSlippagePct", () => { + it("computes settled vs quoted as a percentage", () => { + expect(computeExecutionSlippagePct("100", "99.5")).toBe(-0.5); + }); + + it("is undefined when no quote amount was captured", () => { + expect(computeExecutionSlippagePct(undefined, "99.5")).toBeUndefined(); + }); + + it("is undefined when the quoted amount is zero", () => { + expect(computeExecutionSlippagePct("0", "99.5")).toBeUndefined(); + }); +}); + +describe("classifyAssetIdentity", () => { + const network = Networks.TESTNET; + + it("classifies native XLM with no issuer", () => { + expect(classifyAssetIdentity("XLM", undefined, network)).toEqual({ + code: "XLM", + type: AssetKind.NATIVE, + }); + }); + + it("classifies a plain classic asset (G-issuer)", () => { + const issuer = Keypair.random().publicKey(); + expect(classifyAssetIdentity("USDC", issuer, network)).toEqual({ + code: "USDC", + issuer, + type: AssetKind.CLASSIC, + }); + }); + + it("collapses XLM moved via the native SAC to native, not soroban", () => { + const nativeSac = Asset.native().contractId(network); + expect(classifyAssetIdentity("XLM", nativeSac, network)).toEqual({ + code: "XLM", + type: AssetKind.NATIVE, + }); + }); + + const makeBalanceMap = (code: string, issuer: string): BalanceMap => + ({ + native: { token: { type: "native", code: "XLM" } }, + [`${code}:${issuer}`]: { + token: { type: "credit_alphanum4", code, issuer: { key: issuer } }, + total: new BigNumber(0), + available: new BigNumber(0), + }, + }) as unknown as BalanceMap; + + it("collapses a classic asset moved via its SAC back to classic, by derivation against a held balance", () => { + const issuer = Keypair.random().publicKey(); + const sacAddress = new Asset("USDC", issuer).contractId(network); + const balances = makeBalanceMap("USDC", issuer); + + expect( + classifyAssetIdentity("USDC", sacAddress, network, balances), + ).toEqual({ code: "USDC", issuer, type: AssetKind.CLASSIC }); + }); + + it("reports a contract with no matching classic balance as Soroban-native", () => { + const unrelatedIssuer = Keypair.random().publicKey(); + const sacAddress = new Asset("SHRIMP", unrelatedIssuer).contractId(network); + + // No balances at all, so there's nothing to collapse against. + expect( + classifyAssetIdentity("SHRIMP", sacAddress, network, {} as BalanceMap), + ).toEqual({ + code: "SHRIMP", + issuer: sacAddress, + type: AssetKind.SOROBAN, + }); + }); + + it("skips a liquidity-pool entry (no token field) instead of throwing", () => { + const issuer = Keypair.random().publicKey(); + const sacAddress = new Asset("USDC", issuer).contractId(network); + // The LP entry is iterated before the real match, so a naive + // `"issuer" in balance.token` throws on it (no `token` field at all) + // before ever reaching the matching classic balance below. + const balances = { + native: { token: { type: "native", code: "XLM" } }, + "POOLID:lp": { + liquidityPoolId: "POOLID", + total: new BigNumber(0), + available: new BigNumber(0), + }, + [`USDC:${issuer}`]: { + token: { + type: "credit_alphanum4", + code: "USDC", + issuer: { key: issuer }, + }, + total: new BigNumber(0), + available: new BigNumber(0), + }, + } as unknown as BalanceMap; + + expect( + classifyAssetIdentity("USDC", sacAddress, network, balances), + ).toEqual({ code: "USDC", issuer, type: AssetKind.CLASSIC }); + }); + + it("does not collapse a genuine Soroban/SEP-41 balance whose token also carries an issuer", () => { + // A locally-injected non-classic token is stored with the contract ID in + // both `contractId` and `token.issuer.key` (see injectLocalTokenBalances) + // - it directly contractId-matches, but must not be misread as classic + // just because its token also has an "issuer" field. + const contractId = new Asset( + "SHRIMP", + Keypair.random().publicKey(), + ).contractId(network); + const balances = { + native: { token: { type: "native", code: "XLM" } }, + [`SHRIMP:${contractId}`]: { + token: { code: "SHRIMP", issuer: { key: contractId } }, + contractId, + total: new BigNumber(0), + available: new BigNumber(0), + }, + } as unknown as BalanceMap; + + expect( + classifyAssetIdentity("SHRIMP", contractId, network, balances), + ).toEqual({ + code: "SHRIMP", + issuer: contractId, + type: AssetKind.SOROBAN, + }); + }); + + it("does not collapse against a held balance with a different code", () => { + const heldIssuer = Keypair.random().publicKey(); + const otherIssuer = Keypair.random().publicKey(); + const sacAddress = new Asset("EUROC", otherIssuer).contractId(network); + const balances = makeBalanceMap("USDC", heldIssuer); + + expect( + classifyAssetIdentity("EUROC", sacAddress, network, balances), + ).toEqual({ code: "EUROC", issuer: sacAddress, type: AssetKind.SOROBAN }); + }); +}); + +describe("getFailureCategory", () => { + const horizonError = ( + operations: string[], + transaction = "tx_failed", + ): ErrorMessage => + ({ + errorMessage: "failed", + response: { + status: 400, + extras: { result_codes: { transaction, operations } }, + }, + }) as unknown as ErrorMessage; + + it("maps slippage-related op codes (also covers quote-expired-at-submit)", () => { + expect( + getFailureCategory( + horizonError(["op_under_dest_min"]), + "op_under_dest_min", + ), + ).toBe(FailureCategory.SLIPPAGE); + expect( + getFailureCategory( + horizonError(["op_too_few_offers"]), + "op_too_few_offers", + ), + ).toBe(FailureCategory.SLIPPAGE); + }); + + it("maps balance, trustline, destination, sequence, auth, and fee codes", () => { + expect( + getFailureCategory(horizonError(["op_underfunded"]), "op_underfunded"), + ).toBe(FailureCategory.BALANCE); + expect( + getFailureCategory(horizonError(["op_no_trust"]), "op_no_trust"), + ).toBe(FailureCategory.TRUSTLINE); + expect( + getFailureCategory(horizonError(["op_src_no_trust"]), "op_src_no_trust"), + ).toBe(FailureCategory.TRUSTLINE); + expect( + getFailureCategory( + horizonError(["op_src_not_authorized"]), + "op_src_not_authorized", + ), + ).toBe(FailureCategory.TRUSTLINE); + expect( + getFailureCategory( + horizonError(["op_no_destination"]), + "op_no_destination", + ), + ).toBe(FailureCategory.DESTINATION); + expect( + getFailureCategory(horizonError([], "tx_bad_seq"), "tx_bad_seq"), + ).toBe(FailureCategory.SEQUENCE); + expect( + getFailureCategory(horizonError([], "tx_bad_auth"), "tx_bad_auth"), + ).toBe(FailureCategory.AUTH); + expect( + getFailureCategory( + horizonError([], "tx_insufficient_fee"), + "tx_insufficient_fee", + ), + ).toBe(FailureCategory.FEE); + }); + + it("maps an unmapped Horizon code to protocol_other", () => { + expect(getFailureCategory(horizonError([], "tx_failed"), "tx_failed")).toBe( + FailureCategory.PROTOCOL_OTHER, + ); + }); + + it("maps the 'unknown' sentinel to unknown when Horizon did answer", () => { + expect(getFailureCategory(horizonError([]), "unknown")).toBe( + FailureCategory.UNKNOWN, + ); + }); + + it("maps to transport when there was no protocol answer at all", () => { + const networkError = { + errorMessage: "Failed to fetch", + response: new TypeError("Failed to fetch"), + } as unknown as ErrorMessage; + expect(getFailureCategory(networkError, "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + expect(getFailureCategory(undefined, "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + }); + + it("maps an answer that carries no verdict — 5xx/408/429/403 without result_codes — to transport, not unknown", () => { + const statusOnlyError = (status: number): ErrorMessage => + ({ + errorMessage: "failed", + response: { status, title: "problem" }, + }) as unknown as ErrorMessage; + expect(getFailureCategory(statusOnlyError(503), "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + expect(getFailureCategory(statusOnlyError(504), "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + expect(getFailureCategory(statusOnlyError(408), "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + expect(getFailureCategory(statusOnlyError(429), "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + expect(getFailureCategory(statusOnlyError(403), "unknown")).toBe( + FailureCategory.TRANSPORT, + ); + // A definitive 4xx rejection without result codes stays unknown. + expect(getFailureCategory(statusOnlyError(400), "unknown")).toBe( + FailureCategory.UNKNOWN, + ); + }); +}); diff --git a/extension/src/helpers/usdVolume.ts b/extension/src/helpers/usdVolume.ts new file mode 100644 index 0000000000..29207976ab --- /dev/null +++ b/extension/src/helpers/usdVolume.ts @@ -0,0 +1,309 @@ +import BigNumber from "bignumber.js"; +import { Asset, Networks } from "stellar-sdk"; + +import { ErrorMessage } from "@shared/api/types"; +import { BalanceMap } from "@shared/api/types/backend-api"; +import { AssetType } from "@shared/api/types/account-balance"; +import { isContractId } from "@shared/api/helpers/soroban"; +import { + findAddressBalance, + isClassicBalance, + isSorobanBalance, +} from "popup/helpers/balance"; + +// --------------------------------------------------------------------------- +// Rounding +// --------------------------------------------------------------------------- + +/** + * Rounds to 2 decimal places, half-up, in decimal space before converting to + * a number. Never `Math.round(x * 100) / 100` — that reintroduces binary + * floating-point error at the half-cent boundary. Never the extension's + * existing `roundUsdValue`, which floors instead of rounding. + */ +export const roundHalfUp2dp = (value: BigNumber.Value): number => + new BigNumber(value).decimalPlaces(2, BigNumber.ROUND_HALF_UP).toNumber(); + +// --------------------------------------------------------------------------- +// Per-leg USD derivation +// --------------------------------------------------------------------------- + +export enum LegUsdStatus { + OK = "ok", + NO_PRICE = "no_price", + ERROR = "error", +} + +interface LegUsdOk { + status: LegUsdStatus.OK; + /** Rounded to 2dp. */ + value: number; + /** Unrounded value, used for slippage math — never emitted directly. */ + unrounded: BigNumber; + /** Snapshot price per unit actually used. */ + rate: number; +} + +interface LegUsdUnpriced { + status: LegUsdStatus.NO_PRICE | LegUsdStatus.ERROR; +} + +export type LegUsdResult = LegUsdOk | LegUsdUnpriced; + +/** + * Derives a leg's USD value from its token amount and the snapshot price for + * its canonical id. A missing price is `no_price`; a price that produces a + * non-finite result is `error`. Never emits 0 for a missing price — that + * status is `no_price`/`error`, not a value of 0. + */ +export const deriveLegUsd = ( + tokenAmount: BigNumber.Value | undefined, + pricePerUnit: string | undefined | null, +): LegUsdResult => { + if (pricePerUnit === undefined || pricePerUnit === null) { + return { status: LegUsdStatus.NO_PRICE }; + } + try { + const amount = new BigNumber(tokenAmount ?? NaN); + const price = new BigNumber(pricePerUnit); + const unrounded = amount.multipliedBy(price); + if (!unrounded.isFinite() || !price.isFinite()) { + return { status: LegUsdStatus.ERROR }; + } + return { + status: LegUsdStatus.OK, + value: roundHalfUp2dp(unrounded), + unrounded, + rate: price.toNumber(), + }; + } catch { + return { status: LegUsdStatus.ERROR }; + } +}; + +// --------------------------------------------------------------------------- +// Slippage +// --------------------------------------------------------------------------- + +/** + * `(destUsd - sourceUsd) / sourceUsd * 100`, from unrounded leg values, + * rounded only at the end. Negative when the user received less USD value + * than they gave up. `undefined` when the source value is zero (no ratio) — + * callers additionally gate this on both legs pricing `ok`. + */ +export const computeUsdSlippagePct = ( + sourceUnrounded: BigNumber, + destUnrounded: BigNumber, +): number | undefined => { + if (sourceUnrounded.isZero() || !sourceUnrounded.isFinite()) { + return undefined; + } + const pct = destUnrounded + .minus(sourceUnrounded) + .dividedBy(sourceUnrounded) + .times(100); + return pct.isFinite() ? roundHalfUp2dp(pct) : undefined; +}; + +/** + * `(settled - quoted) / quoted * 100`, token-denominated and price-independent. + * `undefined` when no quote amount was captured or it was zero. + */ +export const computeExecutionSlippagePct = ( + quotedAmount: BigNumber.Value | undefined, + settledAmount: BigNumber.Value | undefined, +): number | undefined => { + if (quotedAmount === undefined || settledAmount === undefined) { + return undefined; + } + const quoted = new BigNumber(quotedAmount); + if (quoted.isZero() || !quoted.isFinite()) { + return undefined; + } + const settled = new BigNumber(settledAmount); + if (!settled.isFinite()) { + return undefined; + } + const pct = settled.minus(quoted).dividedBy(quoted).times(100); + return pct.isFinite() ? roundHalfUp2dp(pct) : undefined; +}; + +// --------------------------------------------------------------------------- +// Asset identity + SAC collapse +// --------------------------------------------------------------------------- + +export enum AssetKind { + NATIVE = "native", + CLASSIC = "classic", + SOROBAN = "soroban", +} + +export interface AssetIdentity { + code: string; + /** `G…` classic issuer or `C…` Soroban-native contract. Omitted for native XLM. */ + issuer?: string; + type: AssetKind; +} + +/** + * Classifies an asset for telemetry, collapsing a classic asset moved via its + * SAC back to its classic identity. Classification is by derivation, not + * heuristic: a `C…` address is only collapsed when it matches the SAC address + * derived from a *known* classic asset with the same code — either native + * XLM, or a classic balance the account itself holds (`balances`). A `C…` + * address with no such match is genuinely Soroban-native. + * + * This means correctness depends on the caller only ever passing a `C…` + * address for an asset the account actually holds (so its classic form is + * findable in `balances`), or on `balances` being fresh. A SAC-wrapped + * classic asset the account does NOT hold — passed as a raw `C…` issuer — + * will be misreported as `soroban` rather than `classic`, with no signal + * that anything went wrong. Today's callers satisfy this: the source leg is + * always drawn from a held-balance picker, and the swap destination leg is + * always pre-normalized to a classic `G…` issuer before it reaches this + * function (the destination picker's classic-only filter, and the + * hardcoded default). If a future caller can supply a `C…` destination for + * an asset not in `balances` — e.g. a raw `destination_asset` query param — + * this function will not catch the misclassification. + */ +export const classifyAssetIdentity = ( + code: string, + issuer: string | undefined, + networkPassphrase: string, + balances?: BalanceMap | null, +): AssetIdentity => { + if (!issuer) { + return { code, type: AssetKind.NATIVE }; + } + + if (!isContractId(issuer)) { + return { code, issuer, type: AssetKind.CLASSIC }; + } + + try { + if (Asset.native().contractId(networkPassphrase) === issuer) { + return { code, type: AssetKind.NATIVE }; + } + + // Reuses the same SAC-collapse derivation the balance pickers use + // (`findAddressBalance`) instead of re-deriving it here, so a future fix + // to SAC matching only needs to land in one place. `isSorobanBalance` is + // still needed alongside `isClassicBalance`: a Soroban balance's token + // also carries `issuer`, so a direct contractId match on a genuine + // Soroban/SEP-41 holding would otherwise structurally pass as "classic". + const match = findAddressBalance( + Object.values(balances ?? {}) as unknown as AssetType[], + issuer, + networkPassphrase as Networks, + ); + + if (match && isClassicBalance(match) && !isSorobanBalance(match)) { + return { + code, + issuer: match.token.issuer.key, + type: AssetKind.CLASSIC, + }; + } + } catch { + // Derivation failed (e.g. an invalid code) — fall through and report the + // contract as Soroban-native rather than throwing out of a telemetry path. + } + + return { code, issuer, type: AssetKind.SOROBAN }; +}; + +// --------------------------------------------------------------------------- +// Failure classification +// --------------------------------------------------------------------------- + +export enum FailureCategory { + SLIPPAGE = "slippage", + FEE = "fee", + BALANCE = "balance", + TRUSTLINE = "trustline", + DESTINATION = "destination", + SEQUENCE = "sequence", + AUTH = "auth", + TRANSPORT = "transport", + PROTOCOL_OTHER = "protocol_other", + UNKNOWN = "unknown", +} + +const REASON_CODE_TO_FAILURE_CATEGORY: Record = { + op_under_dest_min: FailureCategory.SLIPPAGE, + op_too_few_offers: FailureCategory.SLIPPAGE, + tx_insufficient_fee: FailureCategory.FEE, + op_underfunded: FailureCategory.BALANCE, + tx_insufficient_balance: FailureCategory.BALANCE, + op_low_reserve: FailureCategory.BALANCE, + op_no_trust: FailureCategory.TRUSTLINE, + op_src_no_trust: FailureCategory.TRUSTLINE, + op_line_full: FailureCategory.TRUSTLINE, + op_not_authorized: FailureCategory.TRUSTLINE, + op_src_not_authorized: FailureCategory.TRUSTLINE, + op_no_issuer: FailureCategory.TRUSTLINE, + op_invalid_limit: FailureCategory.TRUSTLINE, + op_no_destination: FailureCategory.DESTINATION, + tx_bad_seq: FailureCategory.SEQUENCE, + tx_too_late: FailureCategory.SEQUENCE, + tx_too_early: FailureCategory.SEQUENCE, + tx_bad_auth: FailureCategory.AUTH, + tx_bad_auth_extra: FailureCategory.AUTH, + tx_no_source_account: FailureCategory.AUTH, +}; + +/** + * True when `error.response` looks like an actual protocol answer (a Horizon + * problem+json body, carrying `extras`/`status`/`title`) rather than a raw + * network/fetch exception. Distinguishes `transport` (no definitive outcome + * at all) from every other category, which all require Horizon to have + * actually answered. + */ +const isDefiniteProtocolAnswer = (error: ErrorMessage | undefined): boolean => { + const response = error?.response as unknown; + if (!response || typeof response !== "object") { + return false; + } + return "extras" in response || "status" in response || "title" in response; +}; + +/** + * True when the HTTP status is one that never judges the transaction itself: + * the outcome is undetermined (5xx — the submission may still have been + * ingested; 408 — timed out) or the request was turned away before Horizon + * evaluated it (429 rate limit, 403 proxy rejection). These are `transport`, + * not `unknown`: a body arrived, but no verdict did. + */ +const isNoVerdictHttpStatus = (status: number): boolean => + status >= 500 || status === 408 || status === 429 || status === 403; + +/** + * Maps a Horizon `reason_code` to a bounded `failure_category`. Bucket + * assignment prioritizes `transport` (submission never got a verdict on the + * transaction) over the reason-code table, since a `reason_code` of + * `"unknown"` is ambiguous between "Horizon rejected it with something we + * don't recognize" and "we never got a verdict at all". `transport` covers + * both no-answer (network/fetch exception) and answered-without-a-verdict + * (5xx/408/429/403 with no `result_codes`); `unknown` is reserved for a + * definitive 4xx rejection that carried no result codes. + */ +export const getFailureCategory = ( + error: ErrorMessage | undefined, + reasonCode: string, +): FailureCategory => { + if (!isDefiniteProtocolAnswer(error)) { + return FailureCategory.TRANSPORT; + } + if (reasonCode === "unknown") { + const status = (error?.response as { status?: unknown } | undefined) + ?.status; + if (typeof status === "number" && isNoVerdictHttpStatus(status)) { + return FailureCategory.TRANSPORT; + } + return FailureCategory.UNKNOWN; + } + return ( + REASON_CODE_TO_FAILURE_CATEGORY[reasonCode] ?? + FailureCategory.PROTOCOL_OTHER + ); +}; diff --git a/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx b/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx index 9f8173d859..60fe161557 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React from "react"; import { useNavigate } from "react-router-dom"; import { useSelector, useDispatch } from "react-redux"; import get from "lodash/get"; @@ -18,9 +18,6 @@ import { } from "popup/ducks/transactionSubmission"; import { View } from "popup/basics/layout/View"; import IconFail from "popup/assets/icon-fail.svg"; -import { emitMetric } from "helpers/metrics"; -import { getAssetFromCanonical } from "helpers/stellar"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; import "./styles.scss"; @@ -31,39 +28,23 @@ interface ErrorDetails { status: string; } +// Renders the user-facing failure screen only — title, explanatory +// notification, and any error-specific messaging/links, chosen by +// classifying `error` into a RESULT_CODES case below. It does not emit +// telemetry: paymentFailed / swapFailed / collectibleSendFailed are NOT +// emitted here. They used to be, from an effect keyed on `error`/`asset`/etc +// — but that re-fires on every remount (double-counting attempted volume). +// useSubmitTxData's fetchData is the single, centralized emit site for every +// terminal event (success and failure alike): it already has the +// confirmation price snapshot and the transaction result in scope, and it +// runs exactly once per submission attempt. export const SubmitFail = () => { - const { error, transactionData } = useSelector(transactionSubmissionSelector); + const { error } = useSelector(transactionSubmissionSelector); const isSwap = useIsSwap(); - const { isCollectible, asset, destinationAsset } = transactionData; const { t } = useTranslation(); const navigate = useNavigate(); const dispatch = useDispatch(); - useEffect(() => { - const resultCodes = getResultCodes(error); - const reasonCode = - resultCodes.operations?.[0] || resultCodes.transaction || "unknown"; - - // A routed/path payment fails as a swap; a collectible send has its own - // terminal event. `network` rides on the common context. - if (isCollectible) { - emitMetric(METRIC_NAMES.collectibleSendFailed, { - reason_code: reasonCode, - }); - } else if (isSwap) { - emitMetric(METRIC_NAMES.swapFailed, { - from_asset_code: getAssetFromCanonical(asset).code, - to_asset_code: getAssetFromCanonical(destinationAsset).code, - reason_code: reasonCode, - }); - } else { - emitMetric(METRIC_NAMES.paymentFailed, { - payment_type: "payment", - reason_code: reasonCode, - }); - } - }, [error, isSwap, isCollectible, asset, destinationAsset]); - const getErrorDetails = (err: ErrorMessage | undefined): ErrorDetails => { const errorDetails: ErrorDetails = { title: "", diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/__tests__/useSubmitTxData.telemetry.test.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/__tests__/useSubmitTxData.telemetry.test.tsx new file mode 100644 index 0000000000..c2d185f62e --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/__tests__/useSubmitTxData.telemetry.test.tsx @@ -0,0 +1,542 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook, act } from "@testing-library/react"; +import { + Account, + Asset, + Keypair, + Operation, + TransactionBuilder, + xdr, +} from "stellar-sdk"; + +import { + MAINNET_NETWORK_DETAILS, + NetworkDetails, +} from "@shared/constants/stellar"; +import { CUSTOM_NETWORK } from "@shared/helpers/stellar"; +import * as ApiInternal from "@shared/api/internal"; +import { makeDummyStore } from "popup/__testHelpers__"; +import { initialState as txSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { emitMetric } from "helpers/metrics"; +import { useSubmitTxData } from "../useSubmitTxData"; + +// The emit site is the unit under test — emitMetric itself is mocked so no +// event ever reaches Amplitude, and every network dependency is mocked below. +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), +})); + +// Post-success refetches are outside the telemetry contract; stub them so the +// test never touches the balance/collectible backends. +jest.mock("helpers/hooks/useGetBalances", () => ({ + useGetBalances: () => ({ + fetchData: jest.fn().mockResolvedValue({ balances: [] }), + }), +})); +jest.mock("helpers/hooks/useGetCollectibles", () => ({ + useGetCollectibles: () => ({ + fetchData: jest.fn().mockResolvedValue({}), + }), +})); + +const PUBLIC_KEY = Keypair.random().publicKey(); +const DESTINATION = Keypair.random().publicKey(); +const USDC_ISSUER = Keypair.random().publicKey(); +const USDC_CANONICAL = `USDC:${USDC_ISSUER}`; +const PASSPHRASE = MAINNET_NETWORK_DETAILS.networkPassphrase; +const CUSTOM_NETWORK_DETAILS: NetworkDetails = { + ...MAINNET_NETWORK_DETAILS, + network: CUSTOM_NETWORK, +}; + +/** A real signed-shape swap transaction, so the settled-amount parse in the + * swap.completed path runs against genuine XDR. */ +const buildSwapXdr = (): string => + new TransactionBuilder(new Account(PUBLIC_KEY, "0"), { + fee: "100", + networkPassphrase: PASSPHRASE, + }) + .addOperation( + Operation.pathPaymentStrictSend({ + sendAsset: Asset.native(), + sendAmount: "100", + destination: PUBLIC_KEY, + destAsset: new Asset("USDC", USDC_ISSUER), + destMin: "90", + path: [], + }), + ) + .setTimeout(0) + .build() + .toXdr(); + +/** Horizon TransactionResult XDR whose single op settled a + * pathPaymentStrictSend for `stroops`. */ +const buildResultXdr = (stroops: string): string => { + const simple = new xdr.SimplePaymentResult({ + destination: xdr.PublicKey.publicKeyTypeEd25519( + Keypair.random().rawPublicKey(), + ), + asset: new Asset("USDC", USDC_ISSUER).toXdrObject(), + amount: BigInt(stroops), + }); + const opResult = xdr.OperationResult.opInner( + xdr.OperationResultTr.pathPaymentStrictSend( + xdr.PathPaymentStrictSendResult.pathPaymentStrictSendSuccess( + new xdr.PathPaymentStrictSendResultSuccess({ + offers: [], + last: simple, + }), + ), + ), + ); + return new xdr.TransactionResult({ + feeCharged: BigInt("100"), + result: xdr.TransactionResultResult.txSuccess([opResult]), + ext: xdr.TransactionResultExt.v0(), + }).toXdr("base64"); +}; + +const makeState = ({ + asset, + destinationAsset = "", + amount = "100", + destinationAmount = "", + tokenPrices = {}, + preparedTransaction = buildSwapXdr(), +}: { + asset: string; + destinationAsset?: string; + amount?: string; + destinationAmount?: string; + tokenPrices?: Record; + /** Null for a classic payment — see the regression test below. */ + preparedTransaction?: string | null; +}) => ({ + auth: { + // The destination is "self-owned" so the addRecentAddress thunk (and its + // backend call) is skipped — recent-address bookkeeping isn't telemetry. + allAccounts: [{ publicKey: DESTINATION, name: "t", imported: false }], + publicKey: PUBLIC_KEY, + }, + cache: { + balanceData: {}, + tokenPrices: { [PASSPHRASE]: { [PUBLIC_KEY]: tokenPrices } }, + }, + transactionSubmission: { + ...txSubmissionInitialState, + transactionData: { + ...txSubmissionInitialState.transactionData, + asset, + amount, + destination: DESTINATION, + destinationAsset, + destinationAmount, + isCollectible: false, + }, + transactionSimulation: { + ...txSubmissionInitialState.transactionSimulation, + preparedTransaction, + }, + }, +}); + +const renderSubmitHook = ( + state: ReturnType, + networkDetails: NetworkDetails = MAINNET_NETWORK_DETAILS, +) => { + const store = makeDummyStore(state); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook( + () => + useSubmitTxData({ + isHardwareWallet: false, + networkDetails, + publicKey: PUBLIC_KEY, + xdr: buildSwapXdr(), + }), + { wrapper }, + ); +}; + +const mockSubmitOk = (resultXdr: string) => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + successful: true, + hash: "txhash", + result_xdr: resultXdr, + }), + }) as unknown as typeof fetch; +}; + +const mockSubmitRejected = (problemJson: Record) => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: async () => problemJson, + }) as unknown as typeof fetch; +}; + +const emitted = (eventName: string): Record => { + const call = (emitMetric as jest.Mock).mock.calls.find( + ([name]) => name === eventName, + ); + expect(call).toBeDefined(); + return call![1]; +}; + +describe("useSubmitTxData terminal-event telemetry", () => { + beforeEach(() => { + jest + .spyOn(ApiInternal, "signFreighterTransaction") + .mockResolvedValue({ signedTransaction: buildSwapXdr() }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + (emitMetric as jest.Mock).mockClear(); + }); + + it("payment.completed carries identity, token amount, and the source-leg USD family (confirmation_fetch)", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook(makeState({ asset: "native" })); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitted(METRIC_NAMES.paymentCompleted)).toEqual({ + payment_type: "payment", + asset_code: "XLM", + asset_type: "native", + amount: 100, + amount_usd_status: "ok", + amount_usd: 50, + amount_usd_rate: 0.5, + amount_usd_source: "token_prices_v2", + amount_usd_price_freshness: "confirmation_fetch", + }); + expect(emitMetric).toHaveBeenCalledTimes(1); + }); + + it("falls back to the display-cache price when the confirmation fetch is still pending (cached_display)", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementation(() => new Promise(() => {})); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook( + makeState({ + asset: "native", + tokenPrices: { native: { currentPrice: "0.4" } }, + }), + ); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitted(METRIC_NAMES.paymentCompleted)).toEqual( + expect.objectContaining({ + amount_usd_status: "ok", + amount_usd: 40, + amount_usd_rate: 0.4, + amount_usd_price_freshness: "cached_display", + }), + ); + }); + + it("swap.completed carries both legs: settled destination from the result XDR, quote, and both slippage figures", async () => { + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ + native: { currentPrice: "0.5" }, + [USDC_CANONICAL]: { currentPrice: "0.55" }, + }); + // Settled for 88 USDC against a quote of 90. + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook( + makeState({ + asset: "native", + destinationAsset: USDC_CANONICAL, + destinationAmount: "90", + }), + ); + await act(async () => { + await result.current.fetchData({ isSwap: true }); + }); + + // One request, both legs' canonical ids. + expect(getTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(getTokenPricesSpy.mock.calls[0][0]).toEqual([ + "native", + USDC_CANONICAL, + ]); + + expect(emitted(METRIC_NAMES.swapCompleted)).toEqual({ + from_asset_code: "XLM", + to_asset_code: "USDC", + from_asset_type: "native", + to_asset_issuer: USDC_ISSUER, + to_asset_type: "classic", + from_amount: 100, + to_amount_quoted: 90, + to_amount: 88, + to_amount_usd_status: "ok", + to_amount_usd: 48.4, + to_amount_usd_rate: 0.55, + // (88 * 0.55 - 100 * 0.5) / (100 * 0.5) * 100 = -3.2 + usd_slippage_pct: -3.2, + // (88 - 90) / 90 * 100 = -2.2222… → -2.22 + execution_slippage_pct: -2.22, + amount_usd_status: "ok", + amount_usd: 50, + amount_usd_rate: 0.5, + amount_usd_source: "token_prices_v2", + amount_usd_price_freshness: "confirmation_fetch", + }); + }); + + it("swap.failed carries from_amount, failure_category: slippage for a submit-time quote expiry, and no destination amounts", async () => { + // Both legs priced (not the partial-fetch case, which has its own + // coverage) - this test is about reason_code/failure_category. + jest.spyOn(ApiInternal, "getTokenPrices").mockResolvedValue({ + native: { currentPrice: "0.5" }, + [USDC_CANONICAL]: { currentPrice: "1.0" }, + }); + mockSubmitRejected({ + status: 400, + title: "Transaction Failed", + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_under_dest_min"], + }, + }, + }); + + const { result } = renderSubmitHook( + makeState({ + asset: "native", + destinationAsset: USDC_CANONICAL, + destinationAmount: "90", + }), + ); + await act(async () => { + await result.current.fetchData({ isSwap: true }); + }); + + const props = emitted(METRIC_NAMES.swapFailed); + expect(props).toEqual( + expect.objectContaining({ + from_asset_code: "XLM", + to_asset_code: "USDC", + to_asset_issuer: USDC_ISSUER, + to_asset_type: "classic", + from_amount: 100, + reason_code: "op_under_dest_min", + failure_category: "slippage", + amount_usd_status: "ok", + amount_usd: 50, + }), + ); + expect(props).not.toHaveProperty("to_amount"); + expect(props).not.toHaveProperty("to_amount_usd"); + expect(props).not.toHaveProperty("to_amount_quoted"); + }); + + it("payment.failed falls back to the transaction-level code when no operation ran (tx_bad_seq)", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitRejected({ + status: 400, + title: "Transaction Failed", + extras: { + result_codes: { + transaction: "tx_bad_seq", + operations: [], + }, + }, + }); + + const { result } = renderSubmitHook(makeState({ asset: "native" })); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitted(METRIC_NAMES.paymentFailed)).toEqual( + expect.objectContaining({ + reason_code: "tx_bad_seq", + failure_category: "sequence", + }), + ); + }); + + it("payment.failed classifies an answered-without-a-verdict 5xx as transport", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitRejected({ status: 503, title: "Service Unavailable" }); + + const { result } = renderSubmitHook(makeState({ asset: "native" })); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitted(METRIC_NAMES.paymentFailed)).toEqual( + expect.objectContaining({ + payment_type: "payment", + asset_code: "XLM", + amount: 100, + reason_code: "unknown", + failure_category: "transport", + }), + ); + }); + + it("emits no_price (never 0) when the snapshot holds no price for the leg", async () => { + jest.spyOn(ApiInternal, "getTokenPrices").mockResolvedValue({}); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook(makeState({ asset: "native" })); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + const props = emitted(METRIC_NAMES.paymentCompleted); + expect(props.amount_usd_status).toBe("no_price"); + expect(props).not.toHaveProperty("amount_usd"); + expect(props).not.toHaveProperty("amount_usd_rate"); + }); + + it("still submits (and emits) a classic payment, which has no prepared transaction", async () => { + // Regression: simulateTx's "classic" arm returns a fee and no payload, so + // transactionSimulation.preparedTransaction is null for every classic + // payment — the built XDR reaches the hook via the `xdr` prop and the + // signing step supplies signedXDR. Guarding on preparedTransaction here + // threw before signing and broke the whole classic send flow. + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook( + makeState({ asset: "native", preparedTransaction: null }), + ); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitted(METRIC_NAMES.paymentCompleted)).toEqual( + expect.objectContaining({ + payment_type: "payment", + asset_code: "XLM", + amount: 100, + amount_usd_status: "ok", + amount_usd: 50, + }), + ); + }); + + it("emits no volume telemetry for a payment on a custom network", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook( + makeState({ asset: "native" }), + CUSTOM_NETWORK_DETAILS, + ); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitMetric).not.toHaveBeenCalled(); + }); + + it("emits no volume telemetry for a failed payment on a custom network", async () => { + jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({ native: { currentPrice: "0.5" } }); + mockSubmitRejected({ + status: 400, + title: "Transaction Failed", + extras: { + result_codes: { transaction: "tx_bad_seq", operations: [] }, + }, + }); + + const { result } = renderSubmitHook( + makeState({ asset: "native" }), + CUSTOM_NETWORK_DETAILS, + ); + await act(async () => { + await result.current.fetchData({ isSwap: false }); + }); + + expect(emitMetric).not.toHaveBeenCalled(); + }); + + it("emits no volume telemetry for a swap on a custom network", async () => { + jest.spyOn(ApiInternal, "getTokenPrices").mockResolvedValue({ + native: { currentPrice: "0.5" }, + [USDC_CANONICAL]: { currentPrice: "0.55" }, + }); + mockSubmitOk(buildResultXdr("880000000")); + + const { result } = renderSubmitHook( + makeState({ + asset: "native", + destinationAsset: USDC_CANONICAL, + destinationAmount: "90", + }), + CUSTOM_NETWORK_DETAILS, + ); + await act(async () => { + await result.current.fetchData({ isSwap: true }); + }); + + expect(emitMetric).not.toHaveBeenCalled(); + }); + + it("emits no volume telemetry for a failed swap on a custom network", async () => { + jest.spyOn(ApiInternal, "getTokenPrices").mockResolvedValue({ + native: { currentPrice: "0.5" }, + [USDC_CANONICAL]: { currentPrice: "1.0" }, + }); + mockSubmitRejected({ + status: 400, + title: "Transaction Failed", + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_under_dest_min"], + }, + }, + }); + + const { result } = renderSubmitHook( + makeState({ + asset: "native", + destinationAsset: USDC_CANONICAL, + destinationAmount: "90", + }), + CUSTOM_NETWORK_DETAILS, + ); + await act(async () => { + await result.current.fetchData({ isSwap: true }); + }); + + expect(emitMetric).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx index 33141ffe77..4bd2cecf36 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx @@ -1,6 +1,7 @@ import { useReducer } from "react"; import { useDispatch, useSelector } from "react-redux"; import { captureException } from "@sentry/browser"; +import BigNumber from "bignumber.js"; import { initialState, reducer, isError } from "helpers/request"; import { AppDispatch } from "popup/App"; @@ -15,10 +16,35 @@ import { useGetCollectibles } from "helpers/hooks/useGetCollectibles"; import { NetworkDetails } from "@shared/constants/stellar"; import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; -import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; -import { getSdk } from "@shared/helpers/stellar"; +import { + getAssetFromCanonical, + getCanonicalFromAsset, + isMainnet, +} from "helpers/stellar"; +import { getSdk, isCustomNetwork } from "@shared/helpers/stellar"; import { AssetIcons } from "@shared/api/types"; import { allAccountsSelector } from "popup/ducks/accountServices"; +import { balancesSelector, tokenPricesSelector } from "popup/ducks/cache"; +import { tokenPricesV2Selector } from "popup/ducks/remoteConfig"; +import { getResultCodes } from "popup/helpers/parseTransaction"; +import { + classifyAssetIdentity, + computeExecutionSlippagePct, + computeUsdSlippagePct, + deriveLegUsd, + getFailureCategory, + LegUsdResult, + LegUsdStatus, +} from "helpers/usdVolume"; +import { + ConfirmationPriceSnapshot, + ConfirmationSnapshotHandle, + startConfirmationPriceSnapshot, +} from "helpers/confirmationPriceSnapshot"; +import { + findPathPaymentStrictSendIndex, + getSettledPathPaymentStrictSendAmount, +} from "helpers/transactionResult"; interface SubmitTxData { status: "success" | "error"; @@ -26,6 +52,35 @@ interface SubmitTxData { error?: string; } +/** + * The `amount_usd`-family properties are named identically on all four + * terminal events so `SUM(amount_usd)` works across event names. + * Everything else about a leg (its identity, its token amount) is named + * differently per event (`asset_*`/`amount` for payment, `from_asset_*`/ + * `from_amount` for swap) and is added by each call site instead. + */ +const buildSourceLegUsdProps = ( + tokenAmount: string, + priceStr: string | undefined, + snapshot: ConfirmationPriceSnapshot, +): { leg: LegUsdResult; usdProps: Record } => { + const leg = deriveLegUsd(tokenAmount, priceStr); + return { + leg, + usdProps: { + amount_usd_status: leg.status, + ...(leg.status === LegUsdStatus.OK + ? { + amount_usd: leg.value, + amount_usd_rate: leg.rate, + amount_usd_source: snapshot.source, + amount_usd_price_freshness: snapshot.freshness, + } + : {}), + }, + }; +}; + function useSubmitTxData({ isHardwareWallet, networkDetails, @@ -44,6 +99,9 @@ function useSubmitTxData({ ); const submission = useSelector(transactionSubmissionSelector); const allAccounts = useSelector(allAccountsSelector); + const allBalancesCache = useSelector(balancesSelector); + const allTokenPricesCache = useSelector(tokenPricesSelector); + const useTokenPricesV2 = useSelector(tokenPricesV2Selector); const { fetchData: fetchBalances } = useGetBalances({ showHidden: false, includeIcons: false, @@ -55,9 +113,11 @@ function useSubmitTxData({ const { transactionData: { asset, + amount, destination, federationAddress, destinationAsset, + destinationAmount, isCollectible, collectibleData, }, @@ -67,12 +127,75 @@ function useSubmitTxData({ const fetchData = async ({ isSwap }: { isSwap: boolean }) => { dispatch({ type: "FETCH_DATA_START" }); + // Declared outside the try so the catch can cancel it. + let snapshotHandle: ConfirmationSnapshotHandle | null = null; try { const payload = { status: "success", } as SubmitTxData; - let signedXDR = transactionSimulation.preparedTransaction!; + // Everything the volume telemetry needs is snapshotted here, at + // confirmation, before signing/submission — amounts and prices are + // frozen together and carried to whichever terminal event fires. + // Skipped entirely for collectible sends (unpriced, out of scope) and + // for custom networks (not real economic activity, shouldn't pollute + // volume metrics). + const isCustom = isCustomNetwork(networkDetails); + const accountBalances = + allBalancesCache[networkDetails.network]?.[publicKey]?.balances ?? null; + const sourceIdentity = + !isCollectible && !isCustom + ? classifyAssetIdentity( + sourceAsset.code, + sourceAsset.issuer, + networkDetails.networkPassphrase, + accountBalances, + ) + : null; + const destAssetParsed = + isSwap && !isCollectible && !isCustom + ? getAssetFromCanonical(destinationAsset) + : null; + const destIdentity = destAssetParsed + ? classifyAssetIdentity( + destAssetParsed.code, + destAssetParsed.issuer, + networkDetails.networkPassphrase, + accountBalances, + ) + : null; + + const cachedDisplayPrices = + allTokenPricesCache[networkDetails.networkPassphrase]?.[publicKey] ?? + null; + snapshotHandle = sourceIdentity + ? startConfirmationPriceSnapshot({ + canonicalIds: [ + getCanonicalFromAsset(sourceIdentity.code, sourceIdentity.issuer), + ...(destIdentity + ? [ + getCanonicalFromAsset( + destIdentity.code, + destIdentity.issuer, + ), + ] + : []), + ], + networkDetails, + useV2: useTokenPricesV2, + cachedDisplayPrices, + }) + : null; + + // Not a non-null assertion and not a guard: `preparedTransaction` is + // legitimately null for a classic payment (simulateTx's "classic" arm + // returns a fee and no payload at all — the built XDR arrives via the + // `xdr` prop instead). It only holds a value for a Soroban/token + // transfer, or for a hardware wallet, where HardwareSign stores the + // already-signed XDR there. Everywhere else the signing step below + // replaces it, so `?? ""` — the same fallback Send/index.tsx uses on + // this field — is the honest starting value. + let signedXDR = transactionSimulation.preparedTransaction ?? ""; if (!isHardwareWallet) { const res = await reduxDispatch( signFreighterTransaction({ @@ -103,32 +226,133 @@ function useSubmitTxData({ // Internal broadcasts are already captured by the payment/swap/ // collectible_send `.completed` events below. if (isSwap) { - // Post-confirmation swap telemetry: the swap actually settled. A - // routed/path payment settles here too — its outcome is a swap. - emitMetric(METRIC_NAMES.swapCompleted, { - from_asset_code: sourceAsset.code, - to_asset_code: getAssetFromCanonical(destinationAsset).code, - }); - // Trustline added only once the combined changeTrust + - // pathPaymentStrictSend transaction confirmed it. Gate on the - // submitted transaction itself rather than the pick-time snapshot — - // a defaulted/deep-linked destination has no snapshot, but the - // changeTrust op it confirmed is right there in the XDR. - const Sdk = getSdk(networkDetails.networkPassphrase); - const submittedTx = Sdk.TransactionBuilder.fromXdr( - signedXDR, - networkDetails.networkPassphrase, - ); - const changeTrustOp = - "operations" in submittedTx - ? submittedTx.operations.find((op) => op.type === "changeTrust") - : undefined; - if (changeTrustOp && "line" in changeTrustOp) { - const { line } = changeTrustOp; - emitMetric(METRIC_NAMES.swapTrustlineAdded, { - asset_code: "code" in line ? line.code : undefined, - asset_issuer: "issuer" in line ? line.issuer : undefined, + if (!isCustom) { + // A swap is never a collectible send, so these were computed above: + // sourceIdentity/destIdentity require !isCollectible && !isCustom, + // snapshotHandle requires sourceIdentity. + if (!sourceIdentity || !destIdentity || !snapshotHandle) { + throw new Error( + "Missing identity/snapshot data for swap telemetry", + ); + } + + // Parsed lazily, here, rather than hoisted above: `signedXDR` is a + // placeholder in some call sites/tests when a swap never actually + // reaches submission, and this parse is only ever needed for a + // settled swap. + const Sdk = getSdk(networkDetails.networkPassphrase); + const submittedTx = Sdk.TransactionBuilder.fromXdr( + signedXDR, + networkDetails.networkPassphrase, + ); + + const snapshot = snapshotHandle.resolve(); + const sourceCanonical = getCanonicalFromAsset( + sourceIdentity.code, + sourceIdentity.issuer, + ); + const destCanonical = getCanonicalFromAsset( + destIdentity.code, + destIdentity.issuer, + ); + const sourceUsd = buildSourceLegUsdProps( + amount, + snapshot.pricesById?.[sourceCanonical]?.currentPrice, + snapshot, + ); + + // Settled destination amount, read from the transaction result — + // never the quote. A user navigating away before the result is + // readable (`not_observed`) has no extension analogue: Horizon's + // response already carries `result_xdr` + // synchronously, so a missing/unparseable read here is a genuine + // derivation failure, reported as `error` rather than + // `not_observed`. + const opIndex = findPathPaymentStrictSendIndex(submittedTx); + const settledDestAmount = getSettledPathPaymentStrictSendAmount( + submitResp.payload.result_xdr, + opIndex, + ); + const destUsd: LegUsdResult | null = + settledDestAmount !== null + ? deriveLegUsd( + settledDestAmount, + snapshot.pricesById?.[destCanonical]?.currentPrice, + ) + : null; + + const executionSlippagePct = + settledDestAmount !== null + ? computeExecutionSlippagePct( + destinationAmount || undefined, + settledDestAmount, + ) + : undefined; + const usdSlippagePct = + sourceUsd.leg.status === LegUsdStatus.OK && + destUsd?.status === LegUsdStatus.OK && + sourceUsd.leg.value !== 0 + ? computeUsdSlippagePct( + sourceUsd.leg.unrounded, + destUsd.unrounded, + ) + : undefined; + + // Post-confirmation swap telemetry: the swap actually settled. A + // routed/path payment settles here too — its outcome is a swap. + emitMetric(METRIC_NAMES.swapCompleted, { + from_asset_code: sourceAsset.code, + to_asset_code: getAssetFromCanonical(destinationAsset).code, + ...(sourceIdentity.issuer + ? { from_asset_issuer: sourceIdentity.issuer } + : {}), + from_asset_type: sourceIdentity.type, + ...(destIdentity.issuer + ? { to_asset_issuer: destIdentity.issuer } + : {}), + to_asset_type: destIdentity.type, + from_amount: new BigNumber(amount || 0).toNumber(), + ...(destinationAmount + ? { + to_amount_quoted: new BigNumber( + destinationAmount, + ).toNumber(), + } + : {}), + ...(settledDestAmount !== null + ? { to_amount: settledDestAmount.toNumber() } + : {}), + to_amount_usd_status: destUsd?.status ?? LegUsdStatus.ERROR, + ...(destUsd?.status === LegUsdStatus.OK + ? { + to_amount_usd: destUsd.value, + to_amount_usd_rate: destUsd.rate, + } + : {}), + ...(usdSlippagePct !== undefined + ? { usd_slippage_pct: usdSlippagePct } + : {}), + ...(executionSlippagePct !== undefined + ? { execution_slippage_pct: executionSlippagePct } + : {}), + ...sourceUsd.usdProps, }); + // Trustline added only once the combined changeTrust + + // pathPaymentStrictSend transaction confirmed it. Gate on the + // submitted transaction itself rather than the pick-time snapshot — + // a defaulted/deep-linked destination has no snapshot, but the + // changeTrust op it confirmed is right there in the XDR. + const changeTrustOp = + "operations" in submittedTx + ? submittedTx.operations.find((op) => op.type === "changeTrust") + : undefined; + if (changeTrustOp && "line" in changeTrustOp) { + const { line } = changeTrustOp; + emitMetric(METRIC_NAMES.swapTrustlineAdded, { + asset_code: "code" in line ? line.code : undefined, + asset_issuer: "issuer" in line ? line.issuer : undefined, + }); + } } } else { const isSelfOwnedDestination = (allAccounts ?? []).some( @@ -141,17 +365,44 @@ function useSubmitTxData({ ); } - if (isCollectible) { - emitMetric(METRIC_NAMES.collectibleSendCompleted, { - collection_address: collectibleData.collectionAddress, - token_id: collectibleData.tokenId, - }); - } else { - // Direct (non-routed) payment outcome. - emitMetric(METRIC_NAMES.paymentCompleted, { - payment_type: "payment", - asset_code: sourceAsset.code, - }); + if (!isCustom) { + if (isCollectible) { + emitMetric(METRIC_NAMES.collectibleSendCompleted, { + collection_address: collectibleData.collectionAddress, + token_id: collectibleData.tokenId, + }); + } else { + // A non-collectible, non-swap, non-custom-network send always + // has these computed above (sourceIdentity/snapshotHandle + // require !isCollectible && !isCustom). + if (!sourceIdentity || !snapshotHandle) { + throw new Error( + "Missing identity/snapshot data for payment telemetry", + ); + } + + const snapshot = snapshotHandle.resolve(); + const sourceCanonical = getCanonicalFromAsset( + sourceIdentity.code, + sourceIdentity.issuer, + ); + const sourceUsd = buildSourceLegUsdProps( + amount, + snapshot.pricesById?.[sourceCanonical]?.currentPrice, + snapshot, + ); + // Direct (non-routed) payment outcome. + emitMetric(METRIC_NAMES.paymentCompleted, { + payment_type: "payment", + asset_code: sourceAsset.code, + ...(sourceIdentity.issuer + ? { asset_issuer: sourceIdentity.issuer } + : {}), + asset_type: sourceIdentity.type, + amount: new BigNumber(amount || 0).toNumber(), + ...sourceUsd.usdProps, + }); + } } } @@ -178,11 +429,112 @@ function useSubmitTxData({ )} ${networkDetails.network}`, ); } + } else if (submitFreighterTransaction.rejected.match(submitResp)) { + // Submission was attempted and we're reacting to its outcome — the + // single, centralized failure-emit site (fixes the old effect-based + // double-emit-on-remount bug in SubmitFail). A pre-submission failure + // (signing, simulation) never reaches here, since nothing above this + // point calls submitFreighterTransaction. + const error = submitResp.payload; + const resultCodes = getResultCodes(error); + // A swap prepending a changeTrust operation reports one code per + // operation (e.g. ["op_success", "op_under_dest_min"]) - the first + // code that actually explains the failure isn't always index 0. + const reasonCode = + resultCodes.operations?.find( + (code) => code !== "op_success" && code !== "op_not_attempted", + ) || + resultCodes.transaction || + "unknown"; + const failureCategory = getFailureCategory(error, reasonCode); + + if (!isCustom) { + if (isCollectible) { + emitMetric(METRIC_NAMES.collectibleSendFailed, { + reason_code: reasonCode, + }); + } else if (isSwap) { + if (!sourceIdentity || !destIdentity || !snapshotHandle) { + throw new Error( + "Missing identity/snapshot data for swap telemetry", + ); + } + + const snapshot = snapshotHandle.resolve(); + const sourceCanonical = getCanonicalFromAsset( + sourceIdentity.code, + sourceIdentity.issuer, + ); + const sourceUsd = buildSourceLegUsdProps( + amount, + snapshot.pricesById?.[sourceCanonical]?.currentPrice, + snapshot, + ); + // swap.failed carries no destination amount/USD at all — identity + // only. This is also the sole emit point for a quote expiring at + // submit (op_under_dest_min / op_too_few_offers): + // failure_category: "slippage" falls out of the same mapping used + // for every other rejection, so no special case is needed here or + // in the Swap view's separate swap.quote_expired recovery flow. + emitMetric(METRIC_NAMES.swapFailed, { + from_asset_code: getAssetFromCanonical(asset).code, + to_asset_code: getAssetFromCanonical(destinationAsset).code, + ...(sourceIdentity.issuer + ? { from_asset_issuer: sourceIdentity.issuer } + : {}), + from_asset_type: sourceIdentity.type, + ...(destIdentity.issuer + ? { to_asset_issuer: destIdentity.issuer } + : {}), + to_asset_type: destIdentity.type, + // The failed event still carries the source token amount; + // only destination amounts/USD are absent on swap.failed. + from_amount: new BigNumber(amount || 0).toNumber(), + reason_code: reasonCode, + failure_category: failureCategory, + ...sourceUsd.usdProps, + }); + } else { + if (!sourceIdentity || !snapshotHandle) { + throw new Error( + "Missing identity/snapshot data for payment telemetry", + ); + } + + const snapshot = snapshotHandle.resolve(); + const sourceCanonical = getCanonicalFromAsset( + sourceIdentity.code, + sourceIdentity.issuer, + ); + const sourceUsd = buildSourceLegUsdProps( + amount, + snapshot.pricesById?.[sourceCanonical]?.currentPrice, + snapshot, + ); + emitMetric(METRIC_NAMES.paymentFailed, { + payment_type: "payment", + asset_code: sourceAsset.code, + ...(sourceIdentity.issuer + ? { asset_issuer: sourceIdentity.issuer } + : {}), + asset_type: sourceIdentity.type, + amount: new BigNumber(amount || 0).toNumber(), + reason_code: reasonCode, + failure_category: failureCategory, + ...sourceUsd.usdProps, + }); + } + } } dispatch({ type: "FETCH_DATA_SUCCESS", payload }); return payload; } catch (error) { + // Pre-submission failure (or a throw after the terminal event already + // emitted): no terminal event will consume the snapshot, so cancel the + // price fetch immediately rather than letting it outlive the flow. + // Idempotent and safe after resolve(). + snapshotHandle?.cancel(); dispatch({ type: "FETCH_DATA_ERROR", payload: error }); return error; } diff --git a/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx b/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx index d0e9ad5ee6..65eb0237fa 100644 --- a/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx +++ b/extension/src/popup/components/__tests__/SubmitTransaction.test.tsx @@ -131,13 +131,15 @@ describe("SubmitTransaction", () => { }); screen.getByTestId("enter-password-submit").click(); }); + + // Confirming the password immediately satisfies both conditions the + // submission effect gates on (account data already loaded, + // hasPrivateKey just turned true), so — with every mock resolving + // synchronously — submission can reach "Sent!" before this poll ever + // observes the loading frame. Assert on the deterministic end state + // instead of a transient one. await waitFor(() => { - expect( - screen.getByTestId("sending-transaction-footer-subtext"), - ).toHaveTextContent( - "You can close this screen, your transaction should be complete in less than a minute.", - ); - expect(screen.getByText("Close")).toBeInTheDocument(); + expect(screen.getByText("Sent!")).toBeInTheDocument(); }); });