Skip to content

Add volume telemetry for swaps and payments with USD pricing - #2984

Merged
JakeUrban merged 20 commits into
masterfrom
claude/amplitude-swap-send-volume-k973lt
Sep 1, 2026
Merged

Add volume telemetry for swaps and payments with USD pricing#2984
JakeUrban merged 20 commits into
masterfrom
claude/amplitude-swap-send-volume-k973lt

Conversation

@JakeUrban

@JakeUrban JakeUrban commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds analytics for swap and send transactions: how much value (in USD) each transaction moved, how the price moved between quote and settlement, and why a transaction failed when it did. Prices are captured once, when the user confirms, so the numbers we report stay consistent even if the market moves before the transaction finishes.

Key Changes

New Telemetry Infrastructure

  • usdVolume.ts: The math and classification behind the numbers we report

    • Converts a token amount into a USD value using the price we captured at confirmation
    • Calculates two kinds of slippage: how much USD value was lost, and how far the settled amount was from the quoted amount
    • Figures out whether an asset is XLM, a classic Stellar asset, or a Soroban token — including recognizing when a Soroban token is really just a classic asset wrapped in a contract, so we don't double-count the same asset under two different names
    • Sorts a failed transaction's error into a small set of understandable categories (bad slippage, fee too low, insufficient balance, missing trustline, unknown destination, timing issue, authorization problem, or a connection problem where we never heard back from the network) instead of reporting raw error codes
  • confirmationPriceSnapshot.ts: Captures prices at the moment the user confirms

    • Kicks off one price lookup covering both the asset being sent and, for swaps, the asset being received, without making the user wait for it
    • Locks in those prices once the transaction finishes, so the report reflects what the user saw, not whatever the price happens to be later
    • If the price lookup hasn't finished in time, falls back to the last price already shown on screen, and cancels the request rather than let it run forever in the background
  • transactionResult.ts: Reads what actually happened on-chain

    • Finds the swap operation inside a submitted transaction
    • Reads the amount the swap actually settled for, not the amount it was originally quoted at

Enhanced Transaction Submission

  • useSubmitTxData.tsx is now the one place that reports a transaction's outcome, for both successes and failures
    • Successful swaps report both assets' details, how much was sent and received, the settled amount vs. the quote, and both slippage numbers
    • Successful sends report the asset and USD value moved
    • Failed sends and swaps report the reason for failure, using the plain-language categories above
    • A swap that fails because its quote expired now gets counted the same way as any other failed swap, instead of being tracked separately and left out of the failure stats
    • Removed an older piece of code that reported failures a second time whenever the failure screen reappeared, which was double-counting

Supporting Changes

  • Bumped the analytics schema version to reflect this change
  • The price-lookup function can now actually be cancelled instead of just having its result ignored
  • Added tests for all the new pieces, including a full test of what gets reported for each outcome (success, failure, quote expiry, price unavailable), with all external calls mocked
  • The failure screen component no longer reports anything itself — it just shows the error message to the user; reporting now happens in the one central place described above

claude and others added 7 commits August 27, 2026 23:42
Implements the requirements in design-docs/swap-send-usd-volume/requirements.md
for the extension. payment.completed/failed and swap.completed/failed now
carry a USD-denominated source-leg value (amount_usd + status/rate/source/
freshness), full asset identity (code/issuer/type, with SAC-to-classic
collapse), token amounts, and — for swaps — the settled destination amount
read from the transaction result, quote vs. settled slippage, and a bounded
failure_category alongside the existing reason_code.

- helpers/usdVolume.ts: half-up USD rounding, per-leg USD derivation,
  slippage math, SAC-collapse asset classification, and the
  reason_code -> failure_category mapping.
- helpers/confirmationPriceSnapshot.ts: one price fetch covering both legs,
  started at confirmation and never blocking signing/submission; falls back
  to the cached display price if still pending at terminal status.
- helpers/transactionResult.ts: reads the settled pathPaymentStrictSend
  destination amount from the transaction's result XDR.
- useSubmitTxData.tsx is now the single, centralized emit site for every
  terminal event (success and failure). This fixes the double-emit-on-
  remount bug in SubmitFail's old effect and means a quote expiring at
  submit now also emits swap.failed (failure_category: slippage) alongside
  the existing swap.quote_expired, with no special-casing needed.
- schema_version bumped to "3" per the versioning requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015guUa7BbRavdRi9bHBSSi1
Four fixes from review against the requirements doc, plus the emit-site test
coverage the helpers-only tests were missing.

- swap.failed now carries from_amount: TR-1 requires every terminal event to
  carry the source token amount, settled or rejected; payment.failed already
  had it.
- failure_category now classifies an answered-without-a-verdict response as
  transport, not unknown: a 5xx (outcome undetermined), 408 (timeout), 429
  (rate limit), or 403 (proxy) without result_codes never judged the
  transaction (TR-72). unknown is reserved for a definitive 4xx rejection
  that carried no result codes.
- The confirmation price fetch is now actually cancelled, not abandoned:
  getTokenPrices takes an optional AbortSignal (a true network abort on the
  v1 path; the v2 request runs in the background worker across a message
  boundary the signal cannot cross, so there it skips an unsent request and
  rejects an unwanted result). resolve() aborts a still-pending fetch, and a
  new cancel() on the handle covers pre-submission failures, called from
  fetchData's catch (TR-11, TR-71).
- A rejected fetch now falls back to the display-cache price (cached_display)
  instead of resolving as confirmation_fetch with no prices, which reported
  priceable legs as no_price (TR-11).

New useSubmitTxData.telemetry.test.tsx covers the emit shapes end to end with
every external call mocked: property sets per event, settled destination
amount parsed from real result XDR, both slippage figures, quote-expiry
rejections landing on swap.failed as slippage, transport classification, the
cached_display fallback, no_price omission, and TR-9's single two-leg price
request.

Note: SubmitTransaction.test.tsx "shows verify account modal and confirms
password" fails identically on the base branch (verified at the merge base);
it is not a regression of this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015guUa7BbRavdRi9bHBSSi1
Comments across the volume telemetry implementation cited a requirements
doc (TR-N ids, §section refs) that lives outside this repo and isn't a
stable reference for anyone reading this code later. Rewrote each comment
to state the reasoning inline instead of pointing at a numbered
requirement, so it stands on its own.

No behavior change. tsc clean; the four affected suites (usdVolume,
confirmationPriceSnapshot, transactionResult, and the emit-site telemetry
test) still pass in full.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015guUa7BbRavdRi9bHBSSi1
…ption

Documents a real edge case found during review: the balances-based SAC
reverse-lookup only works when the account holds the asset (so its classic
form is findable) or when the balances cache is fresh. A SAC-wrapped
classic asset the account doesn't hold, passed as a raw C... issuer, gets
silently misreported as soroban.

Both current call sites avoid this - the source leg is always drawn from a
held-balance picker, and the swap destination leg is always normalized to
a classic G... issuer before reaching this function. Flagging it so a
future caller (e.g. a destination_asset query param path that isn't
currently wired up) doesn't reintroduce it unknowingly.

No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015guUa7BbRavdRi9bHBSSi1
… doing

The comment only described the removed telemetry effect and why it was
removed. Added a leading line stating the component's actual current
responsibility - rendering the failure screen by classifying the error into
a RESULT_CODES case - before the historical note.

No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015guUa7BbRavdRi9bHBSSi1
…ap-send-volume-k973lt

# Conflicts:
#	extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx
v17's generated XDR types replaced the old flat union interfaces with
per-arm classes: TransactionResultExt/InnerTransactionResultExt are now
abstract and must be built via their .v0() factory instead of `new`, and
TransactionResultResult/OperationResult/OperationResultTr/
PathPaymentStrictSendResult only expose their arm-specific fields after
narrowing on `.type`, so a bare `.results`/`.tr`/`.success` access no
longer type-checks without an explicit narrowing check first.
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-4335d8cc8e17849d1ff2
Backend: V1 prod + V2 beta (no sandbox configured for @JakeUrban). SDF collaborators only — install instructions in the release description.

@JakeUrban
JakeUrban marked this pull request as ready for review August 28, 2026 20:49
Copilot AI balanced review requested due to automatic review settings August 28, 2026 20:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds USD-volume, slippage, asset classification, and failure telemetry to internal swap and payment flows.

Changes:

  • Captures confirmation-time prices with cancellation and cache fallback.
  • Reports settled swap/payment outcomes and categorized failures.
  • Adds telemetry helpers, schema v3, and unit/integration coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
useSubmitTxData.tsx Centralizes terminal transaction telemetry.
useSubmitTxData.telemetry.test.tsx Tests emitted transaction metrics.
SubmitFail/index.tsx Removes duplicate failure emission.
usdVolume.ts Implements USD, slippage, identity, and failure calculations.
usdVolume.test.ts Tests volume helpers.
transactionResult.ts Extracts settled swap amounts from result XDR.
transactionResult.test.ts Tests result-XDR parsing.
metrics.ts Bumps analytics schema to v3.
metrics.test.ts Updates schema assertions.
confirmationPriceSnapshot.ts Captures cancellable confirmation-time prices.
confirmationPriceSnapshot.test.ts Tests snapshot and fallback behavior.
@shared/api/internal.ts Adds cancellation support to price requests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread extension/src/helpers/transactionResult.ts Outdated
- findPathPaymentStrictSendIndex always returned -1 for fee-bump swaps:
  FeeBumpTransaction exposes operations via innerTransaction.operations,
  not a top-level operations property.
- The failure reason code always took operations[0], but a swap that
  prepends changeTrust reports one result code per operation
  (["op_success", "op_under_dest_min"]) - now picks the first code that
  isn't itself a success/no-op.
- submitFreighterTransaction's custom-network branch discarded the
  Horizon error's structured response, so failure-category
  classification always degraded to "transport" on custom networks.
- The confirmation price snapshot's cached-display fallback didn't
  include the destination's stellar.expert spot-price fallback that the
  receive card itself uses for non-held tokens, so it could under-report
  a price the user actually saw on screen.
Copilot AI review requested due to automatic review settings August 28, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

extension/src/helpers/confirmationPriceSnapshot.ts:97

  • On the cached fallback path, source describes the endpoint requested now, not the endpoint that produced cachedDisplayPrices. If the use_token_prices_v2 kill switch changes after the display cache was populated, a v1 cached rate is labeled token_prices_v2 (or vice versa), corrupting source attribution. Persist the cache's price-source metadata and use it here, or report an explicit cached/unknown source.
        source,

Comment thread extension/src/helpers/confirmationPriceSnapshot.ts
JakeUrban reviewed and rejected two of the four Copilot findings addressed
in b4dee37:

- The destination spot-price fallback in the confirmation price snapshot:
  spotPrice is fetched at a different time from a different source than
  /token-prices, so folding it into the snapshot's cached-display fallback
  was the wrong call - omitting it is correct.
- The custom-network Horizon error response passthrough in
  submitFreighterTransaction: this telemetry isn't emitted for custom
  networks at all, so the fix was moot.

The fee-bump operations unwrap (transactionResult.ts) and the
reasonCode index-0 fix (useSubmitTxData.tsx) stand - only those two were
reverted.
Copilot AI review requested due to automatic review settings August 28, 2026 21:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread extension/src/helpers/usdVolume.ts Outdated
sourceUsd.leg.value is rounded to 2dp for display, so a nonzero source
value under half a cent (e.g. $0.003) rounds to 0 and was silently
dropping usd_slippage_pct - even though computeUsdSlippagePct already
checks the unrounded value for an actual zero. Gate on leg status only
and let it decide.

Also adds a regression test confirming reasonCode still falls back to
the transaction-level code (tx_bad_seq) when no operation ran.
Copilot AI review requested due to automatic review settings August 28, 2026 21:21
…tination

REASON_CODE_TO_FAILURE_CATEGORY mapped op_no_trust/op_not_authorized (the
destination-side path-payment/payment result codes) but not their source-side
counterparts, op_src_no_trust/op_src_not_authorized. Those are common (the
sender lacks a trustline or authorization for the asset it's sending) and were
silently falling through to protocol_other instead of trustline.
@JakeUrban JakeUrban self-assigned this Aug 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx:403

  • A rejected signing action does reach this branch indirectly: the signing block above only replaces signedXDR on fulfillment and otherwise continues by submitting the unsigned preparedTransaction. That makes a local signing failure look like a network/protocol failure (commonly tx_bad_auth) in the new telemetry. Stop the flow when signFreighterTransaction is rejected before dispatching submitFreighterTransaction.
        // 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.

extension/src/helpers/transactionResult.ts:56

  • A txFailed result is atomic and did not settle any operation, even if an earlier per-operation entry is op_success. Accepting it here can therefore return a destination amount that never moved, contrary to this helper's contract. Only decode operation results when the inner transaction result is txSuccess.
    if (
      innerTxResult.type !== "txSuccess" &&
      innerTxResult.type !== "txFailed"
    ) {

extension/src/helpers/usdVolume.ts:227

  • Strict-send/path-payment failures can return op_src_no_trust or op_src_not_authorized for the source trustline. These source-side counterparts currently fall through to protocol_other, so the bounded failure category is inaccurate. Map them alongside the equivalent destination trustline codes.
  op_no_trust: "trustline",
  op_src_no_trust: "trustline",
  op_line_full: "trustline",
  op_not_authorized: "trustline",

Copilot AI review requested due to automatic review settings August 28, 2026 21:26
Reverts the usdSlippagePct gate change from c651aaa per author feedback -
keeping sourceUsd.leg.value !== 0 alongside the leg-status checks. The
reasonCode transaction-fallback regression test added in that same commit
stands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

extension/src/helpers/usdVolume.ts:176

  • This lookup misses a supported SAC-only balance. injectLocalTokenBalances explicitly represents a locally saved SAC without a classic trustline as CODE:CONTRACT_ID while retaining the wrapped CODE:G_ISSUER in balance.name (@shared/api/helpers/injectLocalTokenBalances.ts:80-113,138-153). Because this loop skips every contract issuer, that asset is reported as soroban; its contract canonical ID is then filtered out by getTokenPrices, so the send also loses USD volume. Resolve SAC identity from the matching contract balance's retained metadata (not only from a separate classic balance), and add this fixture as a regression test.
    const classicMatch = Object.values(balances ?? {}).find(
      (balance): boolean => {
        if (!("issuer" in balance.token)) {
          return false;
        }
        const classicIssuer = balance.token.issuer.key;
        if (isContractId(classicIssuer) || balance.token.code !== code) {

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx:182

  • This starts a telemetry-only price request even when usage-data sharing is disabled. emitMetric returns immediately for opted-out users (helpers/metrics.ts:574-577), so the fetched snapshot is guaranteed to be discarded while still adding a backend request to every send/swap. Gate snapshot creation and the associated telemetry derivation on settingsDataSharingSelector, or make the snapshot helper return a no-request handle when consent is off.
      snapshotHandle = sourceIdentity
        ? startConfirmationPriceSnapshot({
            canonicalIds: [
              getCanonicalFromAsset(sourceIdentity.code, sourceIdentity.issuer),
              ...(destIdentity
                ? [
                    getCanonicalFromAsset(
                      destIdentity.code,
                      destIdentity.issuer,
                    ),
                  ]
                : []),
            ],
            networkDetails,
            useV2: useTokenPricesV2,
            cachedDisplayPrices,
          })

@piyalbasu

Copy link
Copy Markdown
Contributor

The custom-network fix reverted in 9b3a3cc was dropped on a premise that doesn't hold

Correction of record / low-severity data quality — not a merge blocker. The affected events are filterable in Amplitude, so nothing pollutes pubnet aggregates.

TL;DR: One of the two fixes reverted in 9b3a3cc was reverted because "this telemetry isn't emitted for custom networks at all." That isn't the case — the failure metrics do fire on custom networks; there's no network gate anywhere between the rejected submission and Amplitude. The practical consequence is small (every custom-network failure reports as an undifferentiated transport failure, and those events carry a network property so they can simply be excluded from analysis), but the one-line fix that was dropped was correct and does work. Flagging mainly so the reasoning doesn't get reapplied — the conclusion "not worth fixing" may still be right, just not for the stated reason.


Detailed explanation (for agents)

The revert: 9b3a3cc dropped response: (e as any)?.response from the custom-network catch added in b4dee37, with the rationale "this telemetry isn't emitted for custom networks at all, so the fix was moot."

Why the premise fails — three independent checks:

  1. No isCustomNetwork reference exists anywhere in useSubmitTxData.tsx, including the submitFreighterTransaction.rejected branch that emits swapFailed / paymentFailed / collectibleSendFailed.

  2. emitMetric gates on exactly two conditions — the data-sharing setting and AMPLITUDE_KEY/hasInitialized. There is no network check:

export const emitMetric = (name: string, body?: Record<string, unknown>) => {
const state = store.getState();
const eventProperties = {
...buildCommonContext(state),
...body,
};
const isDataSharingAllowed = settingsDataSharingSelector(state);
if (!isDataSharingAllowed) {
return;
}
if (!AMPLITUDE_KEY || !hasInitialized) {
console.log(LOG_MESSAGES.EVENT_NOT_UPLOADED, name, eventProperties);
return;
}
amplitude.track(name, eventProperties);
};

  1. The custom-network submit path runs through the SDK's Horizon server (submitTx({ server, tx }) in @shared/api/internal.ts), and SDK Horizon errors carry the problem-details body on .response — so the data the reverted line forwarded was genuinely there and genuinely dropped.

Resulting behaviour on custom networks:

The custom branch rejects with errorMessage only, where the non-custom branch forwards response in both of its reject paths:

if (isCustomNetwork(networkDetails)) {
try {
const txRes = await internalSubmitFreighterTransaction({
signedXDR,
networkDetails,
});
return txRes;
} catch (e) {
const message = e instanceof Error ? e.message : JSON.stringify(e);
return thunkApi.rejectWithValue({
errorMessage: message,
});
}
} else {

Downstream, isDefiniteProtocolAnswer tests error?.response and short-circuits to "transport" when it's absent, and getResultCodes reads response.extras.result_codes.* and yields "unknown":

export const getFailureCategory = (
error: ErrorMessage | undefined,
reasonCode: string,
): FailureCategory => {
if (!isDefiniteProtocolAnswer(error)) {
return "transport";
}
if (reasonCode === "unknown") {
const status = (error?.response as { status?: unknown } | undefined)
?.status;
if (typeof status === "number" && isNoVerdictHttpStatus(status)) {
return "transport";
}
return "unknown";
}
return REASON_CODE_TO_FAILURE_CATEGORY[reasonCode] ?? "protocol_other";
};

So every custom-network failure emits failure_category: "transport", reason_code: "unknown", regardless of what Horizon actually returned.

Why this is still low severity: buildCommonContext stamps network on every event, so these rows are identifiable and can be excluded from any query. They do not silently corrupt pubnet/testnet failure-category breakdowns — they just make custom-network failures uninformative.

Suggested fixes:

  1. Un-revert: restore the single line from b4dee37 (response: (e as any)?.response in the custom-network catch). Restores correct classification with no other behaviour change.
  2. Or close it deliberately: if custom-network failure telemetry genuinely isn't worth classifying, that's a fine call — but then it's worth a comment at the reject site saying so, since the current asymmetry between the two branches reads as an oversight rather than a decision.

@piyalbasu

Copy link
Copy Markdown
Contributor

Five new union type aliases where code-style.md asks for enums

Style / guidance compliance — not a merge blocker.

TL;DR: The best-practices doc has an explicit rule preferring enums over union type aliases for finite sets of named string values, and it closes by naming the four union types still left in the codebase and saying "Don't add more." This PR adds five. They're all closed sets of string constants used as telemetry property values, which is exactly the shape the rule describes. Mechanical to convert, and worth doing before this lands so the count goes down over time rather than up.


Detailed explanation (for agents)

The rule, stated verbatim:

Never use `const enum` — all enums in the codebase are standard runtime enums.
### Prefer Enums Over Union Type Aliases
When you have a finite set of named string values, use an enum, not a union
type:
```typescript
// BAD — union type for a finite set
type SwapStatus = "pending" | "confirming" | "completed" | "failed";
type InputType = "crypto" | "fiat";
type AssetVisibility = "visible" | "hidden";
type NotificationType = "warning" | "info";
// GOOD — enum
enum SwapStatus {
Pending = "pending",
Confirming = "confirming",
Completed = "completed",
Failed = "failed",
}
```
The codebase has a few remaining union types that should be enums (`InputType`,
`AssetVisibility`, `NotificationType`, `PillType`). Don't add more.

The codebase has a few remaining union types that should be enums (InputType, AssetVisibility, NotificationType, PillType). Don't add more.

The five added here:

extension/src/helpers/usdVolume.ts

  • LegUsdStatus
    status: LegUsdStatus;
    /** Rounded to 2dp. Present only when status === "ok". */
    value?: number;
  • AssetKind
    code: string;
    /** `G…` classic issuer or `C…` Soroban-native contract. Omitted for native XLM. */
    issuer?: string;
  • FailureCategory
    // ---------------------------------------------------------------------------
    // Failure classification
    // ---------------------------------------------------------------------------
    export type FailureCategory =
    | "slippage"
    | "fee"
    | "balance"
    | "trustline"
    | "destination"
    | "sequence"
    | "auth"

extension/src/helpers/confirmationPriceSnapshot.ts

Suggested fix: straight conversion, e.g.

export enum FailureCategory {
  Slippage = "slippage",
  Fee = "fee",
  // ...
}

REASON_CODE_TO_FAILURE_CATEGORY keeps working as Record<string, FailureCategory> with the enum members as values, and the emitted Amplitude strings are unchanged since the enum values are the same literals. Same for the other four.

One thing to weigh: FailureCategory and LegUsdStatus are consumed as Amplitude property values, so the string values are a wire contract — the conversion must preserve them exactly (it does, if written as above). If there's a deliberate reason these five were left as unions that the rule didn't anticipate, worth saying so in a comment so the next reader doesn't re-litigate it.

@piyalbasu

Copy link
Copy Markdown
Contributor

Question: is dropping failure telemetry for pre-submission failures intended?

Question / scope check — not a merge blocker, but worth an answer before merge since it changes what the dashboards measure.

TL;DR: Today the three failure metrics fire whenever the user lands on the failure screen, whatever put them there — including failures that happen before the transaction is ever submitted, like a rejected signing or a failed simulation. This PR moves the emit to the submission-rejected path only, which fixes a real double-counting bug but also means those pre-submission failures now emit nothing at all. That's a narrower definition of "failed" than what's currently being recorded, so any existing failure-rate chart will step down when this ships for a reason unrelated to anything actually improving. Is the narrower definition what was wanted, or should pre-submission failures still emit somewhere?


Detailed explanation (for agents)

BeforeSubmitFail emitted from an effect keyed on error, i.e. on any render of the failure screen regardless of how the flow got there:

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]);

After — the only emit site is the submitFreighterTransaction.rejected branch in useSubmitTxData:

} 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);

The move itself is right — the old effect re-fired on remount and double-counted, which is called out in the new file comment, and centralizing next to the price snapshot and transaction result is the correct shape. The question is only about the events that fall outside the new site's reach.

Concretely, what no longer emits: anything that reaches the failure screen without submitFreighterTransaction having been dispatched — a rejected/failed sign, a simulation error, and any pre-flight validation that routes to SubmitFail.

A related nit in the same area: the new comment at the rejected branch asserts

A pre-submission failure (signing, simulation) never reaches here, since nothing above this point calls submitFreighterTransaction.

That's true as a statement about this branch, but it reads as though pre-submission failures are handled elsewhere, when the change is that they're no longer counted at all. Worth rewording to say that explicitly — it's the kind of thing that will otherwise be re-derived by whoever next investigates a failure-rate discontinuity.

Possible resolutions:

  1. Intended: keep as-is, and note in the PR description that *.failed now means "submitted and rejected" so the dashboard step-change is expected and explainable.
  2. Not intended: add a separate emit for pre-submission failures — either a distinct event name, or the same event with a failure_category value that marks it as pre-submission (the category enum already has room for it).

@CassioMG

Copy link
Copy Markdown
Contributor

Telemetry emission relies on ~30 non-null assertions instead of narrowing

Suggestion / robustness — correct today, but brittle against future edits; not a merge blocker.

TL;DR: The centralized telemetry emission uses roughly thirty non-null assertions on three values that are genuinely null for collectible sends. The assertions are safe today only because every branch that uses them happens to be unreachable for collectibles, but that invariant lives far from the use sites and nothing enforces it — a future edit that breaks it would throw mid-flow in a path that's supposed to degrade gracefully, rather than fail visibly at a type check. The project's anti-patterns guide calls this exact pattern out; narrowing once per branch would eliminate all the assertions without changing behavior.


Detailed explanation (for agents)

Root cause: sourceIdentity, destIdentity, and snapshotHandle are all typed X | null, conditionally initialized on !isCollectible:

allBalancesCache[networkDetails.network]?.[publicKey]?.balances ?? null;
const sourceIdentity = !isCollectible
? classifyAssetIdentity(
sourceAsset.code,
sourceAsset.issuer,
networkDetails.networkPassphrase,
accountBalances,
)
: null;
const destAssetParsed =
isSwap && !isCollectible
? getAssetFromCanonical(destinationAsset)
: null;
const destIdentity = destAssetParsed
? classifyAssetIdentity(
destAssetParsed.code,
destAssetParsed.issuer,
networkDetails.networkPassphrase,
accountBalances,
)
: null;

They are then consumed with ! assertions across the swap/payment success and failure branches — e.g. the swap-completion block:

const snapshot = snapshotHandle!.resolve();
const sourceCanonical = getCanonicalFromAsset(
sourceIdentity!.code,
sourceIdentity!.issuer,
);
const destCanonical = getCanonicalFromAsset(
destIdentity!.code,
destIdentity!.issuer,
);

and similarly at lines 283–290, 348–365, 419–445, and 454–470. Each site relies on the code-distant invariant "this branch only runs when isCollectible is false" (e.g. isSwap and isCollectible being mutually exclusive), which the type system cannot see and no runtime guard checks.

Guidance: docs/skills/freighter-best-practices/references/anti-patterns.md, "Non-null Assertions on Optional Data": "// WRONG: crashes silently if icons[asset] is undefined — const icon = icons[asset]!; ... Always handle the missing case explicitly."

Failure mode if the invariant ever breaks: a TypeError thrown from the emission block lands in the surrounding error handling and can misreport the outcome of an already-submitted transaction — the telemetry layer taking down the reporting of a successful send, which is the scenario the centralization in this PR was designed to avoid.

Suggested fixes (in increasing order of depth):

  1. Narrow once per branch: at the top of each non-collectible emission block, guard and bind locals — if (!sourceIdentity || !snapshotHandle) return; const src = sourceIdentity; — so every subsequent use is assertion-free and a broken invariant degrades to "no telemetry emitted" instead of a throw.
  2. Restructure: extract the swap/payment emission into a helper that takes non-null sourceIdentity/snapshotHandle (and destIdentity for swaps) as required parameters, so the null case is handled exactly once at the call site and the compiler enforces it everywhere else.

@CassioMG

Copy link
Copy Markdown
Contributor

Partial price-fetch responses discard cached fallback prices

Low severity — telemetry-only gap on swaps whose destination token the price API doesn't cover; not a merge blocker.

TL;DR: When the confirmation-time price fetch succeeds but only covers some of the requested tokens, the snapshot keeps just the fresh response and throws away the cached display prices it was holding as a fallback — so the uncovered leg gets reported as unpriced even though a usable price was captured at confirmation. When the fetch fails outright, the code does fall back to those cached prices, so a partial success paradoxically yields worse coverage than a total failure. The code's own stated principle for the failure path is that coverage takes priority over freshness; merging fresh-first, cached-otherwise per token would apply that same principle to partial responses. (This differs from the earlier spot-price discussion — spot price is a different source; the cached display prices come from the same token-prices API, just captured earlier.)

Steps to reproduce:

  1. Swap from a held token to a destination token the account doesn't hold and the token-prices endpoint doesn't cover, while the display cache has a price for it.
  2. Let the swap succeed with the confirmation-time fetch resolving before submission finishes.
  3. The emitted swap.completed event reports the destination leg with to_amount_usd_status: "no_price", despite a cached price having been available at confirmation.

Detailed explanation (for agents)

Root cause: in startConfirmationPriceSnapshot, resolve() returns pricesById: fetchedPrices wholesale when the fetch succeeded — no per-canonical-ID merge with cachedDisplayPrices for IDs the response omitted. The failure branch immediately below does use cachedDisplayPrices, making a partial success strictly worse than a total failure for the uncovered leg:

})
.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: () => {
if (succeeded) {
return {
pricesById: fetchedPrices,
freshness: "confirmation_fetch",
source,
};
}
// Pending, rejected, 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: "cached_display",
source,
};
},

The catch block's comment states the intended priority — "coverage takes priority over freshness" — which the success branch doesn't honor for partially-covered responses.

Trigger path: getSwapDerivedData.ts shows /token-prices can lack entries for non-held destination tokens, so a swap to an unpopular token is a realistic partial-coverage case. The caller passes cachedDisplayPrices from tokenPricesSelector (same API source, older snapshot):

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,
})

Test gap: confirmationPriceSnapshot.test.ts covers full success and full failure, but not a partial success (request two IDs, receive one).

Suggested fixes (in increasing order of depth):

  1. Per-ID merge: in the success branch, resolve each requested canonical ID as fresh value first, cached display value otherwise. Per-leg freshness provenance could then be reported instead of a single snapshot-level freshness if disambiguation matters downstream.
  2. If intentional: keep the all-or-nothing behavior but document in resolve() why a partially-fresh snapshot must not mix freshness across legs, and add a partial-response test pinning it.

…asserts

Per team review:

- LegUsdStatus, AssetKind, FailureCategory, PriceSource, PriceFreshness are
  now enums instead of string union type aliases, matching the project's
  established convention (freighter-best-practices: "Prefer Enums Over
  Union Type Aliases"). String values are unchanged, so emitted telemetry
  is unaffected.
- LegUsdResult is now a real discriminated union (LegUsdOk | LegUsdUnpriced)
  keyed on the enum, so `unrounded`/`value`/`rate` narrow from a
  `status === LegUsdStatus.Ok` check instead of needing `!`.
- Replaced every remaining non-null assertion in useSubmitTxData.tsx
  (sourceIdentity!, destIdentity!, snapshotHandle!,
  transactionSimulation.preparedTransaction!) with explicit narrowing
  guards (`if (!x) throw`) placed where the correlated invariant actually
  holds (non-collectible for source/snapshot, additionally non-collectible
  swap for dest).
- startConfirmationPriceSnapshot: a confirmation-fetch result that omits
  any requested canonical id (e.g. a non-held swap destination
  /token-prices has no entry for) is no longer used at all, even for the
  ids it does cover - falls back wholesale to the display-cache prices
  instead of merging, since a partial fetch isn't trustworthy enough to
  use partially.

Verified: tsc clean, full test:ci green (219/219 suites), build:extension
compiles.
Copilot AI review requested due to automatic review settings August 31, 2026 21:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

extension/src/helpers/confirmationPriceSnapshot.ts:115

  • On this fallback path, source describes the endpoint selected for the new confirmation request, not the endpoint that produced cachedDisplayPrices. Because the use_token_prices_v2 kill switch can change after the cache was populated, a cached v2 rate can be reported as token_prices_v1 (or vice versa). Carry the cached rate’s provenance with the cache, or omit/mark the source unknown when that provenance is unavailable.
      return {
        pricesById: cachedDisplayPrices,
        freshness: PriceFreshness.CachedDisplay,
        source,
      };

Comment thread @shared/api/internal.ts
Adds an explicit isCustomNetwork guard in useSubmitTxData so
swap/payment/collectible-send completed and failed events never fire for
custom networks, instead of restoring the previously reverted
transactionSubmission.ts response passthrough.
Copilot AI review requested due to automatic review settings August 31, 2026 22:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

@shared/api/internal.ts:768

  • The new cancellation behavior is not exercised at this API boundary. The snapshot tests mock getTokenPrices, so they only verify that its signal is aborted—not that a pre-aborted v2 call skips fetchBackendV2, that an in-flight v2 result is rejected after abort, or that v1 forwards the signal to fetch. Please add those cases to @shared/api/__tests__/internal.test.ts so the cancellation guarantee cannot regress.
    if (signal?.aborted) {
      throw new DOMException("token-prices request aborted", "AbortError");

833f8ab replaced `transactionSimulation.preparedTransaction!` with a
`if (!preparedTransaction) throw` guard while converting non-null
assertions to narrowing. That guard is wrong: simulateTx's "classic" arm
returns only a recommendedFee and no payload, so preparedTransaction is
null for every classic payment. Its XDR reaches the hook via the `xdr`
prop and the signing step supplies signedXDR, which is why the old
assertion was harmless - the value was always overwritten before use.
preparedTransaction only carries a value for a Soroban/token transfer, or
for a hardware wallet, where HardwareSign stores the signed XDR there.

The guard threw before signing, so every classic send failed outright.
That's what CI caught: the three hard e2e failures were all classic XLM
payments, never reaching /submit-tx or the post-submit balance refetch.

Uses `?? ""` instead - the same fallback Send/index.tsx already applies to
this field - so there's no non-null assertion and no behavior change.

Adds a regression test covering preparedTransaction: null; makeState now
parameterizes the field, which is why the existing telemetry tests (all
of which set it) missed this.
Copilot AI review requested due to automatic review settings August 31, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx:294

  • leg.value is already rounded to two decimals, so a real source value below $0.005 becomes 0 here and suppresses USD slippage even though the unrounded denominator is nonzero. Check unrounded.isZero() (or let computeUsdSlippagePct perform its existing zero check) so low-value swaps still report their percentage.
              sourceUsd.leg.value !== 0

extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx:172

  • The confirmation price request starts even when data sharing is disabled, although every eventual emitMetric call is then guaranteed to no-op. This adds an unnecessary backend request for every opted-out payment/swap, and the v2 request cannot be cancelled once dispatched. Gate snapshot creation and its dependent telemetry branches on settingsDataSharingSelector.
      snapshotHandle = sourceIdentity
        ? startConfirmationPriceSnapshot({

@CassioMG

CassioMG commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

New enums deviate from the repo's member-naming convention

Style / consistency — naming only, no behavior or telemetry impact; not a merge blocker.

TL;DR: The five new enums from the union-type conversion use PascalCase member names, while the project's style guide and every existing enum in the codebase use SCREAMING_SNAKE_CASE members. The string values are untouched, so nothing changes on the wire — this is purely about keeping the codebase's enum shape uniform, and it's cheapest to align now while the enums are brand new and only referenced within this PR's files.


Detailed explanation (for agents)

Convention: docs/skills/freighter-best-practices/references/code-style.md ("Enum Values") labels the pattern explicitly — "PascalCase enum with SCREAMING_SNAKE string values", e.g. export enum ActionStatus { IDLE = "IDLE", ... }. Every existing PascalCase-named enum in the repo conforms: ActionStatus, RequestState, AccountType, WalletType, NetworkCongestion all use SCREAMING_SNAKE members.

Deviating enums (all introduced by the union→enum conversion):

  • LegUsdStatus (Ok, NoPrice, Error):

export enum LegUsdStatus {
Ok = "ok",
NoPrice = "no_price",
Error = "error",
}

  • AssetKind (Native, Classic, Soroban):

export enum AssetKind {
Native = "native",
Classic = "classic",
Soroban = "soroban",
}

  • FailureCategory (Slippage, Fee, Balance, Trustline, Destination, Sequence, Auth, Transport, ProtocolOther, Unknown):

export enum FailureCategory {
Slippage = "slippage",
Fee = "fee",
Balance = "balance",
Trustline = "trustline",
Destination = "destination",
Sequence = "sequence",
Auth = "auth",
Transport = "transport",
ProtocolOther = "protocol_other",
Unknown = "unknown",
}

  • PriceSource (TokenPricesV1, TokenPricesV2) and PriceFreshness (ConfirmationFetch, CachedDisplay):

export enum PriceSource {
TokenPricesV1 = "token_prices_v1",
TokenPricesV2 = "token_prices_v2",
}
export enum PriceFreshness {
ConfirmationFetch = "confirmation_fetch",
CachedDisplay = "cached_display",
}

Fix: rename members to SCREAMING_SNAKE (LegUsdStatus.NO_PRICE, FailureCategory.PROTOCOL_OTHER, PriceSource.TOKEN_PRICES_V1, …) keeping the string values exactly as they are. Mechanical rename across this PR's files only — the enums are new, so no call sites exist outside the PR.

@CassioMG CassioMG left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good to me! Aside from the name convention nit

Per CassioMG's review: code-style.md's "Enum Values" section shows
SCREAMING_SNAKE members regardless of how the enum itself is named, and
every existing enum in the repo follows it (ActionStatus, RequestState,
AccountType, WalletType, NetworkCongestion). The five enums added by the
union->enum conversion used PascalCase members instead.

Mechanical rename of members only - LegUsdStatus, AssetKind,
FailureCategory, PriceSource, PriceFreshness. Every string value is
byte-for-byte unchanged, so the Amplitude wire contract is untouched;
the telemetry tests assert on those raw literals ("ok",
"confirmation_fetch", ...) and still pass unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1rnFig2zYeUtbwnszHQwc
Copilot AI review requested due to automatic review settings September 1, 2026 18:28

Copy link
Copy Markdown
Contributor Author

Done in a9cd6a5 — confirmed the convention and renamed the members on all five enums.

Checked the guidance before applying: code-style.md's "Enum Values" section shows SCREAMING_SNAKE members under every naming style it lists, including the PascalCase-named ActionStatus example, and the existing enums all match (ActionStatus, RequestState, AccountType, WalletType, NetworkCongestion). So this was a straight deviation on my part, not a case worth documenting as deliberate.

LegUsdStatus, AssetKind, FailureCategory, PriceSource, PriceFreshness — members only; every string value is byte-for-byte unchanged, so the Amplitude wire contract is untouched. Worth noting the telemetry tests assert on those raw literals ("ok", "no_price", "confirmation_fetch", "protocol_other", …) and passed without modification, which pins the values independently of the enum members.

tsc clean, eslint clean, full suite green (219/219 suites, 1760 tests).


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx:198

  • If signFreighterTransaction rejects, signedXDR remains "", but execution still dispatches submitFreighterTransaction below. This sends an invalid transaction, overwrites the original signing error with a submission error, and now emits a misleading payment/swap failure even though submission should never have been attempted. Return/throw immediately unless signing fulfilled with a non-empty payload before dispatching the submit thunk.
      let signedXDR = transactionSimulation.preparedTransaction ?? "";

@JakeUrban
JakeUrban merged commit 193a8df into master Sep 1, 2026
12 checks passed
@JakeUrban
JakeUrban deleted the claude/amplitude-swap-send-volume-k973lt branch September 1, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants