|
| 1 | +import { getCryptoCurrencyById } from "@domain/entity-currency-crypto"; |
| 2 | +import { GENERIC_TRANSACTION_MODE } from "./generic-coin-framework/types"; |
| 3 | +import { MODE_TRAITS, RESOURCE_STAKING_OPERATION_TYPES } from "@ledgerhq/coin-tron/logic/modes"; |
| 4 | +import { |
| 5 | + deriveEarnTransactionType, |
| 6 | + deriveFromOperationType, |
| 7 | +} from "@ledgerhq/transaction-observability"; |
| 8 | + |
| 9 | +/** |
| 10 | + * Drift guard for the earn funnel's staking vocabulary. |
| 11 | + * |
| 12 | + * `@ledgerhq/transaction-observability` classifies transactions by matching family-specific |
| 13 | + * wording, but it cannot import the coin layer — live-common depends on it, so the edge would |
| 14 | + * be a cycle. Its own tests therefore assert its maps against hardcoded expectations, which |
| 15 | + * proves they are self-consistent and nothing more: rename a mode upstream and those tests |
| 16 | + * still pass while production silently classifies nothing. |
| 17 | + * |
| 18 | + * This test closes that hole from the side that *can* see both. It reads the real, runtime |
| 19 | + * vocabularies and fails when one gains a value the classifier does not know — so a coin |
| 20 | + * module change surfaces here rather than as a hole in the funnel. |
| 21 | + * |
| 22 | + * Add a family here whenever it exposes its modes as runtime values (most declare them as |
| 23 | + * types only, which cannot be enumerated). |
| 24 | + */ |
| 25 | + |
| 26 | +// Modes that move funds rather than stake them. Listed explicitly so that adding a mode |
| 27 | +// upstream fails the test until someone decides which side it belongs on. |
| 28 | +const GENERIC_NON_STAKING = new Set(["send", "changeTrust", "send-legacy", "send-eip1559"]); |
| 29 | +const TRON_NON_STAKING = new Set(["send"]); |
| 30 | + |
| 31 | +describe("staking vocabulary drift", () => { |
| 32 | + describe("the generic coin framework", () => { |
| 33 | + it("classifies every staking mode it defines", () => { |
| 34 | + const unclassified = GENERIC_TRANSACTION_MODE.filter( |
| 35 | + mode => |
| 36 | + !GENERIC_NON_STAKING.has(mode) && |
| 37 | + deriveEarnTransactionType("newchain", mode) === undefined, |
| 38 | + ); |
| 39 | + expect(unclassified).toEqual([]); |
| 40 | + }); |
| 41 | + |
| 42 | + it("claims nothing for the modes that are not staking", () => { |
| 43 | + for (const mode of GENERIC_NON_STAKING) { |
| 44 | + expect(deriveEarnTransactionType("newchain", mode)).toBeUndefined(); |
| 45 | + } |
| 46 | + }); |
| 47 | + }); |
| 48 | + |
| 49 | + // Tron is the family that has already migrated onto the generic framework while keeping its |
| 50 | + // own wording, so it is the live example of the drift this guard exists for. |
| 51 | + describe("tron", () => { |
| 52 | + it("classifies every mode tron supports", () => { |
| 53 | + const unclassified = Object.keys(MODE_TRAITS).filter( |
| 54 | + mode => |
| 55 | + !TRON_NON_STAKING.has(mode) && deriveEarnTransactionType("tron", mode) === undefined, |
| 56 | + ); |
| 57 | + expect(unclassified).toEqual([]); |
| 58 | + }); |
| 59 | + |
| 60 | + // Its mode -> OperationType table is exported at runtime, so the broadcast-stage map can be |
| 61 | + // checked against the real thing instead of a copy of it. |
| 62 | + it("agrees with tron's own mode-to-operation-type table", () => { |
| 63 | + const disagreements = [...RESOURCE_STAKING_OPERATION_TYPES.entries()] |
| 64 | + .map(([mode, operationType]) => ({ |
| 65 | + mode, |
| 66 | + operationType, |
| 67 | + fromMode: deriveEarnTransactionType("tron", mode), |
| 68 | + fromOperationType: deriveFromOperationType("tron", operationType), |
| 69 | + })) |
| 70 | + .filter(row => row.fromMode !== row.fromOperationType); |
| 71 | + expect(disagreements).toEqual([]); |
| 72 | + }); |
| 73 | + }); |
| 74 | +}); |
| 75 | + |
| 76 | +/** |
| 77 | + * Currency id -> the family it resolves to -> a staking mode that family accepts. |
| 78 | + * |
| 79 | + * The classifier keys on **family**, which is what lets one cosmos row cover osmo, dydx and the |
| 80 | + * rest. That only holds while these ids keep resolving to the family the classifier has a map |
| 81 | + * for, and the classifier itself cannot check it: the currency registry lives in |
| 82 | + * `@domain/entity-currency-crypto`, which `@ledgerhq/transaction-observability` does not depend |
| 83 | + * on. So it is checked here, against the real registry, end to end — id, family, action. |
| 84 | + * |
| 85 | + * A currency re-homed to another family (as tron was, onto the generic coin framework) fails |
| 86 | + * this rather than silently dropping out of the funnel. |
| 87 | + */ |
| 88 | +const STAKING_CURRENCIES: Array<[string, string, string]> = [ |
| 89 | + ["cardano", "cardano", "delegate"], |
| 90 | + ["celo", "celo", "vote"], |
| 91 | + ["cosmos", "cosmos", "delegate"], |
| 92 | + ["osmo", "cosmos", "delegate"], |
| 93 | + ["dydx", "cosmos", "delegate"], |
| 94 | + ["injective", "cosmos", "delegate"], |
| 95 | + ["mantra", "cosmos", "delegate"], |
| 96 | + ["zenrock", "cosmos", "delegate"], |
| 97 | + ["xion", "cosmos", "delegate"], |
| 98 | + ["axelar", "cosmos", "delegate"], |
| 99 | + ["quicksilver", "cosmos", "delegate"], |
| 100 | + ["persistence", "cosmos", "delegate"], |
| 101 | + ["sei_evm", "evm", "delegate"], |
| 102 | + ["monad", "evm", "delegate"], |
| 103 | + ["somnia", "evm", "delegate"], |
| 104 | + ["zero_gravity", "evm", "delegate"], |
| 105 | + ["hedera", "hedera", "delegate"], |
| 106 | + ["elrond", "multiversx", "delegate"], |
| 107 | + ["near", "near", "stake"], |
| 108 | + ["polkadot", "polkadot", "bond"], |
| 109 | + ["solana", "solana", "stake.createAccount"], |
| 110 | + ["sui", "sui", "delegate"], |
| 111 | + ["tezos", "tezos", "delegate"], |
| 112 | + ["tron", "tron", "freeze"], |
| 113 | +]; |
| 114 | + |
| 115 | +describe("staking currencies still resolve to a family the classifier knows", () => { |
| 116 | + it.each(STAKING_CURRENCIES)("%s is a %s currency", (currencyId, family) => { |
| 117 | + expect(getCryptoCurrencyById(currencyId).family).toBe(family); |
| 118 | + }); |
| 119 | + |
| 120 | + it.each(STAKING_CURRENCIES)( |
| 121 | + "%s reaches a staking action through its real family", |
| 122 | + (currencyId, _family, mode) => { |
| 123 | + const family = getCryptoCurrencyById(currencyId).family; |
| 124 | + expect(deriveEarnTransactionType(family, mode)).toBeDefined(); |
| 125 | + }, |
| 126 | + ); |
| 127 | +}); |
0 commit comments