Skip to content

Commit 39b8979

Browse files
committed
feat(observability): correlate the sign and broadcast stages
The signed operation is the same object both stages see, so object identity is the correlation key — a WeakMap, hence no TTL, no eviction policy and no retained signatures. Where it hits, the broadcast event carries the transaction's own action wording, delegation target and send-max. That matters most where the optimistic operation is generic: hedera claim-rewards and algorand claimReward report OUT, solana stake.withdraw reports IN. A miss (serialised signed operations, ACRE) falls back to the operation type, and tx_data_source records which path ran.
1 parent 3ada3da commit 39b8979

6 files changed

Lines changed: 252 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@ledgerhq/transaction-observability": minor
3+
"@ledgerhq/live-common": minor
4+
---
5+
6+
Correlate the sign and broadcast stages, so a broadcast event carries the transaction's own data rather than what survives on the optimistic operation.
7+
8+
`signOperation` emits a `SignedOperation` and that same object is later handed to `broadcast`, so object identity is the correlation key — nothing to invent, nothing to reconcile. A `WeakMap` means no TTL, no eviction policy and no size cap to get wrong, and no signature is retained: a transaction signed but never broadcast simply becomes garbage.
9+
10+
Without this, the broadcast stage is uneven in ways a data consumer cannot predict. Cosmos copies its validators into the optimistic operation and Solana does not; Hedera's `claim-rewards` and Algorand's `claimReward` are crafted as plain transfers and so report `OUT`, and Solana's `stake.withdraw` reports `IN` — indistinguishable from an incoming transfer. Correlation recovers the exact action, the delegation target and send-max for all of them.
11+
12+
Correlation legitimately misses when a signed operation is serialised and rehydrated (the wallet-api `transaction.sign` route, or one persisted and broadcast later) and for ACRE, which signs outside the wrapper. Those fall back to the operation type. `tx_data_source` on every event records which path produced it, so the hit rate is measurable rather than assumed. Route attribution still comes from the broadcast stage, which is the only stage that knows it.

libs/transaction-observability/src/eventBuilders.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Account, AccountLike, Operation, SignedOperation } from "@ledgerhq/types-live";
1+
import type { Account, AccountLike, SignedOperation } from "@ledgerhq/types-live";
22
import { setEnv } from "@shared/env";
33
import {
44
buildBroadcastCommonEvent,
@@ -19,7 +19,8 @@ const cosmos = account({ id: "cosmos", family: "cosmos", ticker: "ATOM" });
1919
const sei = account({ id: "sei_evm", family: "evm", ticker: "SEI" });
2020

2121
const tx = (fields: Record<string, unknown>): TransactionLike => fields;
22-
const operation = (fields: Record<string, unknown>) => fields as unknown as Operation;
22+
const signed = (op: Record<string, unknown>) =>
23+
({ signature: "0xsig", operation: op }) as unknown as SignedOperation;
2324

2425
const attribution = (mainAccount: Account, over: Partial<{ account: AccountLike }> = {}) => ({
2526
account: over.account ?? mainAccount,
@@ -86,7 +87,7 @@ describe("buildBroadcastCommonEvent", () => {
8687
it("derives the action from the optimistic operation type", () => {
8788
const common = buildBroadcastCommonEvent({
8889
...attribution(cosmos),
89-
operation: operation({ type: "DELEGATE", extra: {} }),
90+
signedOperation: signed({ type: "DELEGATE", extra: {} }),
9091
});
9192

9293
expect(common).toMatchObject({
@@ -99,7 +100,7 @@ describe("buildBroadcastCommonEvent", () => {
99100
it("reads the validators cosmos copies into the operation extra", () => {
100101
const common = buildBroadcastCommonEvent({
101102
...attribution(cosmos),
102-
operation: operation({
103+
signedOperation: signed({
103104
type: "DELEGATE",
104105
extra: { validators: [{ address: "cosmosvaloper1" }] },
105106
}),
@@ -111,7 +112,7 @@ describe("buildBroadcastCommonEvent", () => {
111112
it("reports no validators for a family that does not copy them across", () => {
112113
const common = buildBroadcastCommonEvent({
113114
...attribution(cardano),
114-
operation: operation({ type: "DELEGATE", extra: {} }),
115+
signedOperation: signed({ type: "DELEGATE", extra: {} }),
115116
});
116117

117118
expect(common.validators).toBeUndefined();
@@ -122,7 +123,7 @@ describe("buildBroadcastCommonEvent", () => {
122123
it("prefers the exact mode off transactionRaw where it survives", () => {
123124
const common = buildBroadcastCommonEvent({
124125
...attribution(sei),
125-
operation: operation({
126+
signedOperation: signed({
126127
type: "REWARD",
127128
extra: {},
128129
transactionRaw: { mode: "compoundReward", valAddress: "0xval" },
@@ -139,7 +140,7 @@ describe("buildBroadcastCommonEvent", () => {
139140
it("falls back to the operation type when transactionRaw carries no usable mode", () => {
140141
const common = buildBroadcastCommonEvent({
141142
...attribution(sei),
142-
operation: operation({ type: "REWARD", extra: {}, transactionRaw: { mode: "send" } }),
143+
signedOperation: signed({ type: "REWARD", extra: {}, transactionRaw: { mode: "send" } }),
143144
});
144145

145146
expect(common).toMatchObject({
@@ -151,7 +152,7 @@ describe("buildBroadcastCommonEvent", () => {
151152
it("derives nothing from a plain send, so it never enters the funnel", () => {
152153
const common = buildBroadcastCommonEvent({
153154
...attribution(cosmos),
154-
operation: operation({ type: "OUT", extra: {} }),
155+
signedOperation: signed({ type: "OUT", extra: {} }),
155156
});
156157

157158
expect(common.earnTransactionType).toBeUndefined();

libs/transaction-observability/src/eventBuilders.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { deriveEarnTransactionType, type EarnTransactionType } from "./earnTransactionType";
1919
import { deriveFromOperationType } from "./operationType";
2020
import { getRawTransactionType, getStakeTarget, type TransactionLike } from "./transactionShape";
21+
import { recallSignContext } from "./signContext";
2122
import { classifyTransactionError, ErrorCategory, toError, unwrapRpcError } from "./errorCategory";
2223

2324
type Attribution = {
@@ -116,10 +117,27 @@ function stakeTargetFromOperation(operation: Operation): string[] | undefined {
116117
* `compoundReward`, since both become `REWARD`.
117118
*/
118119
export function buildBroadcastCommonEvent(
119-
attribution: Attribution & { operation: Operation },
120+
attribution: Attribution & { signedOperation: SignedOperation },
120121
): CommonLogEvent {
121-
const { operation, mainAccount } = attribution;
122+
const { signedOperation, mainAccount } = attribution;
123+
const { operation } = signedOperation;
122124
const family = mainAccount.currency.family;
125+
126+
// The sign stage saw the real transaction. Where that correlates, its data is strictly
127+
// better than anything recoverable here — and for the families that report a generic
128+
// operation type it is the only thing that makes the action legible at all.
129+
const signed = recallSignContext(signedOperation);
130+
if (signed?.earnTransactionType) {
131+
return buildCommon(attribution, {
132+
...signed,
133+
// Celo and tron put the target only on the optimistic operation (`celoSourceValidator`,
134+
// `extra.votes`), where the sign stage cannot see it — so take whichever stage has one
135+
// rather than letting a correlation hit throw the other away.
136+
validators: signed.validators ?? stakeTargetFromOperation(operation),
137+
dataSource: TransactionDataSource.Sign,
138+
});
139+
}
140+
123141
const rawMode = (operation.transactionRaw as { mode?: string } | undefined)?.mode;
124142
const fromRawMode = deriveEarnTransactionType(family, rawMode);
125143

libs/transaction-observability/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export { deriveFromOperationType } from "./operationType";
1515

1616
export { getRawTransactionType, getStakeTarget, type TransactionLike } from "./transactionShape";
1717

18+
export { rememberSignContext, type SignContext } from "./signContext";
19+
1820
export {
1921
buildBroadcastCommonEvent,
2022
buildSignCommonEvent,
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { Account, SignedOperation } from "@ledgerhq/types-live";
2+
import { setEnv } from "@shared/env";
3+
import { hasSignContext, recallSignContext, rememberSignContext } from "./signContext";
4+
import { buildBroadcastCommonEvent } from "./eventBuilders";
5+
import { TransactionDataSource, TransactionFlow } from "./logEvent";
6+
7+
const account = (id: string, family: string, ticker: string) =>
8+
({ id: "acc", type: "Account", currency: { id, family, ticker } }) as unknown as Account;
9+
10+
const signed = (op: Record<string, unknown>) =>
11+
({ signature: "0xsig", operation: op }) as unknown as SignedOperation;
12+
13+
const attribution = (mainAccount: Account) => ({
14+
account: mainAccount,
15+
mainAccount,
16+
flow: TransactionFlow.Send,
17+
});
18+
19+
beforeEach(() => setEnv("LEDGER_CLIENT_VERSION", "llc/test"));
20+
21+
describe("rememberSignContext / recallSignContext", () => {
22+
it("carries the exact action and the delegation target across the stages", () => {
23+
const signedOperation = signed({ type: "DELEGATE", extra: {} });
24+
rememberSignContext(signedOperation, "solana", {
25+
family: "solana",
26+
model: { kind: "stake.createAccount", uiState: { voteAccAddr: "voteAcc" } },
27+
});
28+
29+
expect(recallSignContext(signedOperation)).toEqual({
30+
earnTransactionType: "delegate",
31+
rawTransactionType: "stake.createAccount",
32+
validators: ["voteAcc"],
33+
isSendMax: false,
34+
});
35+
});
36+
37+
it("does not correlate a different signed operation", () => {
38+
rememberSignContext(signed({ type: "DELEGATE" }), "cosmos", {
39+
family: "cosmos",
40+
mode: "delegate",
41+
});
42+
43+
expect(recallSignContext(signed({ type: "DELEGATE" }))).toBeUndefined();
44+
expect(recallSignContext(undefined)).toBeUndefined();
45+
});
46+
47+
it("stores nothing for a non-staking transaction", () => {
48+
const signedOperation = signed({ type: "OUT" });
49+
rememberSignContext(signedOperation, "cosmos", { family: "cosmos", mode: "send" });
50+
51+
expect(recallSignContext(signedOperation)?.earnTransactionType).toBeUndefined();
52+
});
53+
54+
it("survives a read, so a rebroadcast still correlates", () => {
55+
const signedOperation = signed({ type: "DELEGATE" });
56+
rememberSignContext(signedOperation, "cosmos", { family: "cosmos", mode: "delegate" });
57+
58+
recallSignContext(signedOperation);
59+
expect(hasSignContext(signedOperation)).toBe(true);
60+
});
61+
});
62+
63+
describe("buildBroadcastCommonEvent with a sign context", () => {
64+
// Solana's stake.withdraw becomes an `IN` operation, indistinguishable from an incoming
65+
// transfer — correlation is the only thing that makes it reportable at all.
66+
it("recovers an action the operation type cannot express", () => {
67+
const solana = account("solana", "solana", "SOL");
68+
const signedOperation = signed({ type: "IN", extra: {} });
69+
rememberSignContext(signedOperation, "solana", {
70+
family: "solana",
71+
model: { kind: "stake.withdraw" },
72+
});
73+
74+
expect(buildBroadcastCommonEvent({ ...attribution(solana), signedOperation })).toMatchObject({
75+
earnTransactionType: "withdraw",
76+
rawTransactionType: "stake.withdraw",
77+
dataSource: TransactionDataSource.Sign,
78+
});
79+
});
80+
81+
it("recovers the validator for a family that drops it from the operation", () => {
82+
const cardano = account("cardano", "cardano", "ADA");
83+
const signedOperation = signed({ type: "DELEGATE", extra: {} });
84+
rememberSignContext(signedOperation, "cardano", {
85+
family: "cardano",
86+
mode: "delegate",
87+
poolId: "pool123",
88+
});
89+
90+
expect(
91+
buildBroadcastCommonEvent({ ...attribution(cardano), signedOperation }).validators,
92+
).toEqual(["pool123"]);
93+
});
94+
95+
// The sign stage has no broadcastConfig, so it must never win on attribution.
96+
it("keeps the broadcast stage's route attribution", () => {
97+
const cosmos = account("cosmos", "cosmos", "ATOM");
98+
const signedOperation = signed({ type: "DELEGATE", extra: {} });
99+
rememberSignContext(signedOperation, "cosmos", { family: "cosmos", mode: "delegate" });
100+
101+
const common = buildBroadcastCommonEvent({
102+
account: cosmos,
103+
mainAccount: cosmos,
104+
flow: TransactionFlow.Dapp,
105+
manifestId: "kiln",
106+
signedOperation,
107+
});
108+
109+
expect(common).toMatchObject({ flow: TransactionFlow.Dapp, manifestId: "kiln" });
110+
});
111+
112+
// Celo and tron only expose the target on the optimistic operation, so a correlation hit
113+
// must not discard it just because the sign stage had none.
114+
it("keeps an operation-only validator when the sign stage had none", () => {
115+
const celo = account("celo", "celo", "CELO");
116+
const signedOperation = signed({ type: "VOTE", extra: { celoSourceValidator: "0xgroup" } });
117+
rememberSignContext(signedOperation, "celo", { family: "celo", mode: "vote" });
118+
119+
const common = buildBroadcastCommonEvent({ ...attribution(celo), signedOperation });
120+
expect(common).toMatchObject({
121+
earnTransactionType: "delegate",
122+
dataSource: TransactionDataSource.Sign,
123+
validators: ["0xgroup"],
124+
});
125+
});
126+
127+
it("falls back to the operation type when nothing correlates", () => {
128+
const cosmos = account("cosmos", "cosmos", "ATOM");
129+
130+
expect(
131+
buildBroadcastCommonEvent({
132+
...attribution(cosmos),
133+
signedOperation: signed({ type: "DELEGATE", extra: {} }),
134+
}),
135+
).toMatchObject({
136+
earnTransactionType: "delegate",
137+
rawTransactionType: "DELEGATE",
138+
dataSource: TransactionDataSource.Broadcast,
139+
});
140+
});
141+
142+
it("falls back when the sign stage saw no staking action", () => {
143+
const cosmos = account("cosmos", "cosmos", "ATOM");
144+
const signedOperation = signed({ type: "DELEGATE", extra: {} });
145+
rememberSignContext(signedOperation, "cosmos", { family: "cosmos", mode: "send" });
146+
147+
expect(buildBroadcastCommonEvent({ ...attribution(cosmos), signedOperation })).toMatchObject({
148+
dataSource: TransactionDataSource.Broadcast,
149+
});
150+
});
151+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { SignedOperation } from "@ledgerhq/types-live";
2+
import { deriveEarnTransactionType, type EarnTransactionType } from "./earnTransactionType";
3+
import { getRawTransactionType, getStakeTarget, type TransactionLike } from "./transactionShape";
4+
5+
/**
6+
* What the sign stage knows and the broadcast stage does not: the family's own action
7+
* wording, the delegation target, and send-max.
8+
*/
9+
export type SignContext = {
10+
earnTransactionType?: EarnTransactionType;
11+
rawTransactionType?: string;
12+
validators?: string[];
13+
isSendMax: boolean;
14+
};
15+
16+
/**
17+
* Correlates the two lifecycle stages.
18+
*
19+
* `signOperation` emits a `SignedOperation` and that same object is later handed to
20+
* `broadcast`, so object identity is the correlation key — no id to invent, nothing to
21+
* reconcile. A `WeakMap` rather than a keyed cache means there is no TTL, no eviction policy
22+
* and no size cap to get wrong, and no signature is retained: a transaction that is signed
23+
* and never broadcast simply becomes garbage. Entries survive a read, so a rebroadcast or a
24+
* speed-up still correlates.
25+
*
26+
* Identity is lost when a signed operation is serialised and rehydrated — the wallet-api
27+
* `transaction.sign` route across the webview boundary, or one persisted and broadcast later
28+
* — and ACRE signs outside the wrapper entirely. Those miss and fall back to the operation
29+
* type, which is why {@link deriveFromOperationType} is still load-bearing.
30+
*/
31+
const contexts = new WeakMap<object, SignContext>();
32+
33+
export function rememberSignContext(
34+
signedOperation: SignedOperation,
35+
family: string,
36+
transaction: TransactionLike | undefined | null,
37+
): void {
38+
if (!signedOperation || typeof signedOperation !== "object") return;
39+
const rawTransactionType = getRawTransactionType(transaction);
40+
contexts.set(signedOperation, {
41+
earnTransactionType: deriveEarnTransactionType(family, rawTransactionType),
42+
rawTransactionType,
43+
validators: getStakeTarget(transaction),
44+
isSendMax: Boolean(transaction?.useAllAmount),
45+
});
46+
}
47+
48+
export function recallSignContext(
49+
signedOperation: SignedOperation | undefined | null,
50+
): SignContext | undefined {
51+
if (!signedOperation || typeof signedOperation !== "object") return undefined;
52+
return contexts.get(signedOperation);
53+
}
54+
55+
/** Test-only: the map is otherwise invisible, so a leak assertion needs a way in. */
56+
export function hasSignContext(signedOperation: SignedOperation): boolean {
57+
return contexts.has(signedOperation);
58+
}

0 commit comments

Comments
 (0)