Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/extension/src/background/runtime/runtime-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
getNftTokenUri,
type getTokenBalances,
getTokenMetadata,
getTransactionFees,
getTransactionStatuses,
type InvokeInput,
type NftOwnerInput,
Expand Down Expand Up @@ -232,6 +233,14 @@ export type RuntimeRpc = {
* to the local log.
*/
readonly getTransactionHistory?: (input: TransactionHistoryScan) => Promise<DiscoveredHistory>;
/**
* Reads the fee each transaction's receipt reports it paid, for the activity
* log. Optional so existing RPC stubs stay valid; when absent no fee is
* recorded. A hash with no usable receipt fee is absent from the answer.
*/
readonly getTransactionFees?: (
input: TransactionStatusInput,
) => ReturnType<typeof getTransactionFees>;
readonly getTransactionStatuses: (
input: TransactionStatusInput,
) => ReturnType<typeof getTransactionStatuses>;
Expand Down Expand Up @@ -352,6 +361,7 @@ export const createDefaultRuntimeRpc = ({ readPreferences }: RuntimeRpcOptions):
clientFor: configuredStarkscanClient,
readPreferences,
}),
getTransactionFees,
getTransactionStatuses,
multisig: {
getConfig: getArgentMultisigConfig,
Expand Down
127 changes: 108 additions & 19 deletions apps/extension/src/background/transactions/tx-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
*/

import type { WalletPreferences, WalletPreferencesStore } from "@bramble/settings/preferences";
import type { TransactionStatusReport } from "@bramble/starknet/rpc";
import {
TRANSACTION_STATUS_CONCURRENCY,
type TransactionFee,
type TransactionStatusReport,
} from "@bramble/starknet/rpc";
import {
type ActivityEntry,
type ActivityLog,
Expand All @@ -32,6 +36,7 @@ import {
expiredObservations,
isObserved,
isObservedAt,
L1_FINALITY_WINDOW_MS,
type ObservationTier,
reconciledOutcome,
retiredOutcome,
Expand Down Expand Up @@ -101,41 +106,120 @@ const expiredOutcomes = (
expiredObservations(entries, nowMs).map((entry) => [entry.hash, retiredOutcome(entry)]),
);

// Reads one network's transactions. A failed status query contributes nothing,
// so those transactions simply stay under observation rather than being misread
// as gone.
// Only an outcome the chain executed has a receipt carrying the fee paid.
const hasReceipt = (status: ActivityOutcome["status"]): boolean =>
status === "accepted" || status === "reverted";

// Only rows this wallet submitted get a fee: a receipt's fee is paid by the
// transaction's sender, so on a discovered incoming transfer it is someone
// else's. A recorded fee is final and never read again.
const wantsFee = (entry: ActivityEntry, status: ActivityOutcome["status"]): boolean =>
entry.origin === "wallet" && entry.feePaid === undefined && hasReceipt(status);

/**
* Wallet rows read for a missing fee per slow pass beyond those a status read
* already covers. A reverted or L1-final row is never observed again, so
* without this a single failed fee read would lose its fee for good.
*/
const FEE_BACKFILL_LIMIT = TRANSACTION_STATUS_CONCURRENCY;

const feeBackfill = (
entries: readonly ActivityEntry[],
due: readonly ActivityEntry[],
nowMs: number,
): readonly ActivityEntry[] =>
entries
.filter(
(entry) =>
!due.includes(entry) &&
wantsFee(entry, entry.status) &&
nowMs - entry.submittedAt < L1_FINALITY_WINDOW_MS,
)
.slice(0, FEE_BACKFILL_LIMIT);

// Reads the receipt fee for each candidate. A failed or missing read records
// nothing; the row is read again on a later pass (its own observation while it
// is still observed, the slow-pass backfill after that).
const receiptFees = async (
deps: ReconcileDeps,
rpcUrls: readonly string[],
candidates: readonly ActivityEntry[],
): Promise<Readonly<Record<string, TransactionFee>>> => {
const read = deps.runtimeRpc.getTransactionFees;
if (read === undefined || candidates.length === 0) {
return {};
}
return read({ hashes: candidates.map((entry) => entry.hash), rpcUrls }).catch(() => ({}));
};

// Reads one network's transactions: the status of every due entry, then the
// fee of each wallet row that now has a receipt and of each backfill row. A
// failed status query contributes nothing, so those transactions simply stay
// under observation rather than being misread as gone; a backfill row
// contributes only when its fee is found.
const networkOutcomes = async (
deps: ReconcileDeps,
preferences: WalletPreferences,
networkId: StarknetNetworkId,
entries: readonly ActivityEntry[],
due: readonly ActivityEntry[],
backfill: readonly ActivityEntry[],
): Promise<readonly (readonly [string, ActivityOutcome])[]> => {
const reports: Readonly<Record<string, TransactionStatusReport>> = await deps.runtimeRpc
.getTransactionStatuses({
hashes: entries.map((entry) => entry.hash),
rpcUrls: networkRpcUrls(STARKNET_NETWORKS[networkId], preferences),
})
.catch(() => ({}));
return entries.flatMap((entry) => {
const rpcUrls = networkRpcUrls(STARKNET_NETWORKS[networkId], preferences);
const reports: Readonly<Record<string, TransactionStatusReport>> =
due.length === 0
? {}
: await deps.runtimeRpc
.getTransactionStatuses({ hashes: due.map((entry) => entry.hash), rpcUrls })
.catch(() => ({}));
const outcomes = due.flatMap((entry) => {
const report = reports[entry.hash];
return report === undefined ? [] : [[entry.hash, reconciledOutcome(entry, report)] as const];
return report === undefined ? [] : [[entry, reconciledOutcome(entry, report)] as const];
});
const fees = await receiptFees(deps, rpcUrls, [
...outcomes
.filter(([entry, outcome]) => wantsFee(entry, outcome.status))
.map(([entry]) => entry),
...backfill,
]);
const withFee = (entry: ActivityEntry, outcome: ActivityOutcome): ActivityOutcome => {
const feePaid = fees[entry.hash];
return feePaid === undefined ? outcome : { ...outcome, feePaid };
};
return [
...outcomes.map(([entry, outcome]) => [entry.hash, withFee(entry, outcome)] as const),
...backfill.flatMap((entry) => {
const feePaid = fees[entry.hash];
return feePaid === undefined
? []
: [[entry.hash, { feePaid, status: entry.status }] as const];
}),
];
};

// Reads the chain for the given entries, one query per network. Networks are
// independent endpoints, so their reads overlap rather than adding up their
// round-trips.
const chainOutcomes = async (
deps: ReconcileDeps,
entries: readonly ActivityEntry[],
due: readonly ActivityEntry[],
backfill: readonly ActivityEntry[] = [],
): Promise<Readonly<Record<string, ActivityOutcome>>> => {
if (entries.length === 0) {
if (due.length === 0 && backfill.length === 0) {
return {};
}
const preferences = await deps.preferencesStore.read();
const dueByNetwork = byNetwork(due);
const backfillByNetwork = byNetwork(backfill);
const networks = new Set([...dueByNetwork.keys(), ...backfillByNetwork.keys()]);
const perNetwork = await Promise.all(
[...byNetwork(entries)].map(([networkId, group]) =>
networkOutcomes(deps, preferences, networkId, group),
[...networks].map((networkId) =>
networkOutcomes(
deps,
preferences,
networkId,
dueByNetwork.get(networkId) ?? [],
backfillByNetwork.get(networkId) ?? [],
),
),
);
return Object.fromEntries(perNetwork.flat());
Expand Down Expand Up @@ -163,7 +247,11 @@ export const reconcileScope = async (
const due = entries.filter((entry) =>
includeSlow ? isObserved(entry, nowMs) : isObservedAt(entry, "fast", nowMs),
);
const outcomes = { ...expiredOutcomes(entries, nowMs), ...(await chainOutcomes(deps, due)) };
const backfill = includeSlow ? feeBackfill(entries, due, nowMs) : [];
const outcomes = {
...expiredOutcomes(entries, nowMs),
...(await chainOutcomes(deps, due, backfill)),
};
return Object.keys(outcomes).length === 0
? entries
: deps.activity.updateStatuses(outcomes, scope);
Expand Down Expand Up @@ -208,7 +296,8 @@ export const reconcileObserved = async (
const entries = await deps.activity.list();
const due = entries.filter((entry) => isObservedAt(entry, tier, nowMs));
const expired = tier === "fast" ? expiredOutcomes(entries, nowMs) : {};
const outcomes = { ...expired, ...(await chainOutcomes(deps, due)) };
const backfill = tier === "slow" ? feeBackfill(entries, due, nowMs) : [];
const outcomes = { ...expired, ...(await chainOutcomes(deps, due, backfill)) };
const touched = entries.filter((entry) => outcomes[entry.hash] !== undefined);
for (const scope of byScope(touched)) {
await deps.activity.updateStatuses(outcomes, scope);
Expand Down
12 changes: 12 additions & 0 deletions packages/features/transactions/src/activity/activity-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* through `@bramble/transactions/activity`, which re-exports everything here.
*/
import { hasOnlyKnownKeys, isRecord } from "@bramble/core/json";
import { isTransactionFee, type TransactionFee } from "@bramble/starknet/rpc";
import { isAccountIdForNetwork } from "@bramble/wallet-model/accounts";
import {
isStarkFieldElement,
Expand Down Expand Up @@ -79,6 +80,14 @@ export type ActivityEntry = {
* shown it. Absent means there is nothing to tell the user.
*/
readonly failureNotice?: "delivered" | "pending";
/**
* The fee the transaction's receipt reports it paid, read once after it
* reached an outcome with a receipt, and only on rows this wallet submitted
* (`origin: "wallet"`), since the sender pays it. Absent until then, on
* legacy and discovered rows, and whenever no node returned a usable fee: an
* estimate is never stored here.
*/
readonly feePaid?: TransactionFee;
/**
* True once the chain reported `ACCEPTED_ON_L1`. Reconciliation's terminal
* condition; not a user-visible state.
Expand Down Expand Up @@ -126,6 +135,7 @@ export type ActivityEntry = {
*/
export type ActivityOutcome = {
readonly failureNotice?: "pending";
readonly feePaid?: TransactionFee;
readonly finalizedOnL1?: boolean;
readonly notFoundCount?: number;
readonly seenOnChain?: boolean;
Expand Down Expand Up @@ -207,6 +217,7 @@ export const isActivityEntry = (value: unknown): value is ActivityEntry =>
"counterparty",
"direction",
"failureNotice",
"feePaid",
"finalizedOnL1",
"hash",
"kind",
Expand Down Expand Up @@ -239,6 +250,7 @@ export const isActivityEntry = (value: unknown): value is ActivityEntry =>
value["direction"] === "out") &&
isOptionalDetail(value["token"]) &&
isFailureNotice(value["failureNotice"]) &&
(value["feePaid"] === undefined || isTransactionFee(value["feePaid"])) &&
(value["finalizedOnL1"] === undefined || typeof value["finalizedOnL1"] === "boolean") &&
(value["seenOnChain"] === undefined || typeof value["seenOnChain"] === "boolean") &&
isNotFoundCount(value["notFoundCount"]) &&
Expand Down
17 changes: 16 additions & 1 deletion packages/features/transactions/src/activity/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ export type ActivityLog = {
readonly record: (
entry: Omit<
ActivityEntry,
"failureNotice" | "finalizedOnL1" | "notFoundCount" | "seenOnChain" | "status" | "submittedAt"
| "failureNotice"
| "feePaid"
| "finalizedOnL1"
| "notFoundCount"
| "seenOnChain"
| "status"
| "submittedAt"
>,
) => Promise<void>;
/**
Expand Down Expand Up @@ -123,6 +129,14 @@ const outcomeForHash = (
): ActivityOutcome | undefined =>
Object.entries(outcomes).find(([candidate]) => sameFelt(candidate, hash))?.[1];

// A recorded fee is final: a later read never replaces it, so an outcome's fee
// lands only on an entry that has none.
const recordedFee = (
entry: ActivityEntry,
outcome: ActivityOutcome,
): Pick<ActivityEntry, "feePaid"> =>
entry.feePaid === undefined && outcome.feePaid !== undefined ? { feePaid: outcome.feePaid } : {};

// Merges one reconciled outcome into a stored entry. Absent optional fields are
// dropped rather than written as `undefined`, so the persisted record stays
// JSON-clean and revalidates; a merge that would not revalidate is discarded.
Expand All @@ -139,6 +153,7 @@ const applyOutcome = (entry: ActivityEntry, outcome: ActivityOutcome): ActivityE
const next: ActivityEntry = {
...entry,
...(failureNotice === undefined ? {} : { failureNotice }),
...recordedFee(entry, outcome),
...(finalizedOnL1 === undefined ? {} : { finalizedOnL1 }),
...(seenOnChain === undefined ? {} : { seenOnChain }),
...(outcome.notFoundCount === undefined ? {} : { notFoundCount: outcome.notFoundCount }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* The optional `feePaid` field on stored activity rows (GitHub 463). Rows from
* before it existed still load, a well-formed fee loads with them, and a
* malformed one fails the row's validation rather than reaching the feed.
*/
import { describe, expect, test } from "bun:test";
import { type ActivityEntry, isActivityEntry } from "@bramble/transactions/activity";

const row: ActivityEntry = {
accountId: "SN_MAIN:0x1",
hash: "0xabc",
kind: "transfer",
networkId: "SN_MAIN",
origin: "wallet",
status: "accepted",
submittedAt: 1_700_000_000_000,
summary: "Sent 1 STRK",
};

describe("feePaid", () => {
test("is optional, so rows stored before it existed still load", () => {
expect(isActivityEntry(row)).toBe(true);
});

test("loads a well-formed fee in either unit", () => {
expect(isActivityEntry({ ...row, feePaid: { amount: "0xabc", unit: "FRI" } })).toBe(true);
expect(isActivityEntry({ ...row, feePaid: { amount: "0x1", unit: "WEI" } })).toBe(true);
});

test("rejects a row whose fee is malformed", () => {
for (const feePaid of [
"0xabc",
{ amount: "0xabc" },
{ amount: "0xABC", unit: "FRI" },
{ amount: "0xabc", unit: "STRK" },
{ amount: "0xabc", note: "estimate", unit: "FRI" },
null,
]) {
expect(isActivityEntry({ ...row, feePaid })).toBe(false);
}
});
});
9 changes: 9 additions & 0 deletions packages/platform/starknet/src/rpc/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ export {
submitInvokeTransaction,
} from "./rpc-submit";
export { isTokenBalance, type TokenBalance } from "./token-balance";
/**
* Re-exported receipt-fee reads so callers keep one RPC import path.
*/
export {
getTransactionFees,
isTransactionFee,
readReceiptFee,
type TransactionFee,
} from "./transaction-fee";
/**
* Re-exported transaction-status reads so callers keep one RPC import path.
*/
Expand Down
Loading
Loading