diff --git a/.github/workflows/ci-checks.yaml b/.github/workflows/ci-checks.yaml index 7c425a8..6277744 100644 --- a/.github/workflows/ci-checks.yaml +++ b/.github/workflows/ci-checks.yaml @@ -1,6 +1,7 @@ # Reusable workflow: validates the subgraph compiles for a given network. -# The compile gate for a subgraph is "does codegen + graph build succeed", -# i.e. do schema.graphql, the ABIs, and the AssemblyScript mappings agree. +# Codegen + graph build verify that schema.graphql, the ABIs, and the +# AssemblyScript mappings agree. Focused handler regression tests additionally +# check deposit divisor snapshots against ordered governance events. # Used by ci.yaml (PR + master, both networks) and by deploy-mainnet.yaml's own # `checks` job (mainnet only, matching that deploy target). # Cutover, guarded-call, and DKG handler regression tests run separately in ci.yaml. @@ -35,6 +36,9 @@ jobs: run: yarn codegen - name: Build subgraph manifest run: yarn run build-${{ inputs.network }} + - name: Check deposit divisor event ordering + if: inputs.network == 'mainnet' + run: node --experimental-vm-modules --test scripts/check-deposit-divisor.test.mjs - name: Check toolchain security and deployment compatibility if: inputs.network == 'mainnet' run: node --test scripts/check-toolchain.test.mjs diff --git a/docs/treasury-fee-divisor.md b/docs/treasury-fee-divisor.md new file mode 100644 index 0000000..960576c --- /dev/null +++ b/docs/treasury-fee-divisor.md @@ -0,0 +1,37 @@ +# Treasury fee divisor at reveal + +`Deposit.treasuryFeeDivisorAtReveal` snapshots the divisor from `BridgeState` +when `DepositRevealed` is handled. `DepositParametersUpdated` changes that state +in event order. A contract call from the reveal handler would instead read the +state at the end of the block, including any later governance update. + +The initial value is **2000**, set by `Bridge.initialize` without emitting +`DepositParametersUpdated`. The proxy does emit `Initialized(1)`, which seeds +the state. Both configured Bridge data sources start at that deployment block: + +| Network | Bridge start block | Initialization evidence | Initializer source | +| --- | --- | --- | --- | +| Mainnet | 16397413 | [Proxy receipt, `Initialized(1)`](https://github.com/threshold-network/tbtc-v2/blob/8a816122aaacc342c90ee2fd77f8ba403bc8431d/solidity/deployments/mainnet/Bridge.json#L2619-L2628) | [Divisor = 2000](https://github.com/threshold-network/tbtc-v2/blob/8a816122aaacc342c90ee2fd77f8ba403bc8431d/solidity/contracts/bridge/Bridge.sol#L304) | +| Sepolia | 4553028 | [Proxy receipt, `Initialized(1)`](https://github.com/threshold-network/tbtc-v2/blob/5628b077cb5d9bb60881e9d50d2ebbcfe70d173f/typescript/src/lib/ethereum/artifacts/sepolia/Bridge.json#L2619-L2628) | [Divisor = 2000](https://github.com/threshold-network/tbtc-v2/blob/5628b077cb5d9bb60881e9d50d2ebbcfe70d173f/solidity/contracts/bridge/Bridge.sol#L304) | + +Later initialization versions do not reset the value. An already tracked value, +including zero, also survives initialization handling. If indexing begins after +initialization without a seed, the divisor remains null until a parameter update +is indexed; it is never guessed from end-of-block state. When adding a new Bridge +deployment, verify its initializer and include its deployment block in the manifest. + +For example, if the current divisor is zero and a block contains a reveal, +an update to 500, then another reveal, the deposits retain zero and 500 +respectively. Further updates leave both snapshots unchanged. + +This change requires a re-sync to populate historical deposits. Bundle deployment +with the next release as planned for PR #23. + +Run the handler regression tests with: + +```sh +node --experimental-vm-modules --test scripts/check-deposit-divisor.test.mjs +``` + +The tests execute the mapping handlers with mocked Graph services. Code generation +and builds for both networks separately check AssemblyScript and schema compatibility. diff --git a/schema.graphql b/schema.graphql index 6823c97..d1abb09 100644 --- a/schema.graphql +++ b/schema.graphql @@ -102,6 +102,27 @@ type Deposit @entity(immutable: false) { l1Sender: Bytes # True once a routed DepositInitialized/DepositFinalized has attached here. isRouted: Boolean + + # --- Treasury fee context --- + # The deposit treasury fee divisor at this reveal's position in event order, + # seeded by Initialized(1) and tracked via DepositParametersUpdated. + # Without it, `treasuryFee: 0` is + # ambiguous: it means either "the protocol charged no fee at the time" or + # "this depositor's fee was waived", and a consumer cannot tell which. + # + # treasuryFee 0, divisor 0 -> no fee regime; nothing was waived + # treasuryFee 0, divisor > 0 -> a genuine full waiver + # treasuryFee below expected -> a partial waiver + # + # Null means neither initialization nor a parameter update has been indexed. + # This is deliberately distinct from a real zero, so missing history is + # never mistaken for "the protocol charged nothing". + # + # Mainnet switched the fee on around 2026-04-15: no deposit before that date + # carries a fee, and every one after is amount/500 apart from waived ones. + # Consumers should read this field rather than hardcode that date, which + # would break the next time governance changes the divisor. + treasuryFeeDivisorAtReveal: BigInt } type Redemption @entity(immutable: false) { @@ -190,6 +211,9 @@ type Wallet @entity(immutable: false) { # (per Codex P2 feedback on the earlier C-1.1a iteration). type BridgeState @entity(immutable: false) { id: ID! + # Current value in event order. Seeded to the Bridge initializer's 2000 on + # Initialized(1), then updated by DepositParametersUpdated. Null until seeded. + depositTreasuryFeeDivisor: BigInt # C-2: current default scheme used by `requestNewWallet`. # Defaults to ECDSA at C-2 activation. Updated by # `handleNewWalletSchemeSet`. diff --git a/scripts/bridge-mapping-harness.mjs b/scripts/bridge-mapping-harness.mjs new file mode 100644 index 0000000..860ec67 --- /dev/null +++ b/scripts/bridge-mapping-harness.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { createContext, SourceTextModule, SyntheticModule } from "node:vm"; + +// Execute the real mapping, with only its Graph host and imported dependencies +// mocked. AssemblyScript compilation remains covered by codegen + graph build; +// this harness checks handler behavior and persisted values in event order. +const mappingPath = new URL("../src/mappingBridge.ts", import.meta.url); + +class GraphBigInt { + constructor(value) { this.value = BigInt(value); Object.freeze(this); } + static fromI32(value) { return new GraphBigInt(value); } + static fromString(value) { return new GraphBigInt(value); } + toI32() { return Number(this.value); } + toString() { return String(this.value); } +} + +class Bytes extends Uint8Array { + static fromByteArray(value) { return new Bytes(value); } + static fromUint8Array(value) { return new Bytes(value); } + static fromHexString(value) { return new Bytes(Buffer.from(value.slice(2), "hex")); } + toHexString() { return `0x${Buffer.from(this).toString("hex")}`; } + equals(other) { return this.toHexString() === other.toHexString(); } +} + +const integer = (value) => GraphBigInt.fromString(String(value)); +const bytes = (value, length = 32) => Bytes.fromHexString(`0x${BigInt(value).toString(16).padStart(length * 2, "0")}`); +const key = (id) => id instanceof Bytes ? id.toHexString() : String(id); +const unexpected = (name) => () => { throw new Error(`Unmocked mapping dependency: ${name}`); }; + +// Graph entities are loaded/saved as independent records. Copy arrays as well +// so mutating a loaded record cannot change a previously persisted snapshot. +function copyEntity(entity) { + const copy = Object.assign(Object.create(Object.getPrototypeOf(entity)), entity); + for (const [name, value] of Object.entries(copy)) { + if (Array.isArray(value)) copy[name] = [...value]; + } + return copy; +} + +function entityType(defaults = () => ({})) { + const records = new Map(); + return class Entity { + constructor(id) { Object.assign(this, defaults(), { id }); } + static load(id) { return records.has(key(id)) ? copyEntity(records.get(key(id))) : null; } + save() { records.set(key(this.id), copyEntity(this)); } + }; +} + +export async function createBridgeHarness({ + endOfBlockDivisor = 500, + mappingSource = readFileSync(mappingPath, "utf8"), +} = {}) { + const BridgeState = entityType(() => ({ depositTreasuryFeeDivisor: null })); + const Deposit = entityType(() => ({ + status: "UNKNOWN", amount: integer(0), treasuryFee: integer(0), + treasuryFeeDivisorAtReveal: null, transactions: [], + })); + const Transaction = entityType(); + const User = entityType(() => ({ deposits: [] })); + const Stats = entityType(() => ({ numDeposits: 0 })); + const WalletSchemeChange = entityType(); + const loadOrCreate = (Type, id) => Type.load(id) ?? new Type(id); + const depositCalls = new Map(); + const warnings = []; + let parameterCalls = 0; + let sequence = 0; + + // Key derivation is outside the behavior under test. Use a deterministic, + // collision-free key for these fixtures, shared by the host call and store. + function calculateDepositKey(hash, index) { + const output = Buffer.alloc(4); + output.writeUInt32BE(index); + return new Bytes(Buffer.concat([Buffer.from(hash), output])); + } + const byteArrayToBigint = (value) => integer(BigInt(`0x${Buffer.from(value).toString("hex")}`)); + const exportsByModule = { + "../generated/Bridge/Bridge": { + Bridge: { bind: () => ({ + try_deposits(id) { + assert.ok(depositCalls.has(id.toString()), "deposit call must match this fixture's funding output"); + return depositCalls.get(id.toString()); + }, + // Deliberately expose the final block value, even for earlier events. + // The historical divisor must be independent of this eth_call result. + try_depositParameters() { + parameterCalls++; + return { reverted: false, value: { value1: integer(endOfBlockDivisor) } }; + }, + }) }, + }, + "@graphprotocol/graph-ts": { + BigInt: GraphBigInt, Bytes, + log: { warning: (...args) => warnings.push(args), info: () => {} }, + }, + "../generated/schema": { BridgeState, WalletSchemeChange }, + "./utils/helper": { + getOrCreateDeposit: (id) => loadOrCreate(Deposit, id), + getOrCreateTransaction: (id) => loadOrCreate(Transaction, id), + getOrCreateUser: (id) => loadOrCreate(User, id), + getOrCreateTbtcToken: () => ({ id: "TBTCToken" }), + getStats: () => loadOrCreate(Stats, "singleton"), + }, + "./utils/utils": { + bytesToUint8Array: (value) => new Uint8Array(value), + calculateDepositKey, byteArrayToBigint, + getIDFromEvent: (event) => `${event.transaction.hash.toHexString()}-${event.logIndex}`, + }, + "./utils/constants": { ZERO_BI: integer(0), ONE_BI: integer(1) }, + "./swept": {}, + "./utils/bitcoin_utils": {}, + }; + + // Type stripping preserves imports. Give imported type-only bindings strict + // placeholders rather than maintaining a duplicate list of Bridge events. + // Handler bodies are neither extracted nor rewritten. + for (const match of mappingSource.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g)) { + const [, bindings, specifier] = match; + assert.ok(Object.hasOwn(exportsByModule, specifier), `Unknown mapping dependency: ${specifier}`); + for (const binding of bindings.split(",")) { + const name = binding.trim().split(/\s+as\s+/)[0]; + if (name && !Object.hasOwn(exportsByModule[specifier], name)) { + exportsByModule[specifier][name] = unexpected(`${specifier}:${name}`); + } + } + } + const context = createContext({ Uint8Array }); + const mapping = new SourceTextModule(stripTypeScriptTypes(mappingSource), { + context, identifier: mappingPath.href, + }); + await mapping.link((specifier) => { + assert.ok(Object.hasOwn(exportsByModule, specifier), `Unknown mapping dependency: ${specifier}`); + const values = exportsByModule[specifier]; + return new SyntheticModule(Object.keys(values), function () { + for (const [name, value] of Object.entries(values)) this.setExport(name, value); + }, { context }); + }); + await mapping.evaluate(); + const handlers = mapping.namespace; + + function event(params) { + sequence++; + return { + params, address: bytes(1, 20), logIndex: integer(sequence), + block: { number: integer(20_000_000), timestamp: integer(1_700_000_000) }, + transaction: { hash: bytes(sequence), from: bytes(2, 20), to: bytes(1, 20) }, + }; + } + return { + handlers, event, bytes, warnings, + state: () => BridgeState.load("singleton"), + deposit: (id) => Deposit.load(id), + stats: () => Stats.load("singleton"), + parameterCalls: () => parameterCalls, + initialize: (version = 1) => handlers.handleInitialized(event({ version })), + update: (divisor) => handlers.handleDepositParametersUpdated(event({ + depositDustThreshold: integer(1000), depositTreasuryFeeDivisor: integer(divisor), + depositTxMaxFee: integer(100), depositRevealAheadPeriod: 0, + })), + reveal({ fee = 0, reverted = false } = {}) { + const reveal = event({ + fundingTxHash: bytes(sequence + 100), fundingOutputIndex: integer(0), + depositor: bytes(2, 20), amount: integer(10_000), walletPubKeyHash: bytes(3, 20), + blindingFactor: bytes(4, 8), refundPubKeyHash: bytes(5, 20), + refundLocktime: bytes(6, 4), vault: bytes(7, 20), + }); + const id = calculateDepositKey(reveal.params.fundingTxHash, 0); + depositCalls.set(byteArrayToBigint(id).toString(), { + reverted, value: { treasuryFee: integer(fee) }, + }); + handlers.handleDepositRevealed(reveal); + return Deposit.load(id); + }, + }; +} diff --git a/scripts/check-deposit-divisor.test.mjs b/scripts/check-deposit-divisor.test.mjs new file mode 100644 index 0000000..372a96b --- /dev/null +++ b/scripts/check-deposit-divisor.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBridgeHarness } from "./bridge-mapping-harness.mjs"; + +const divisor = (deposit) => deposit.treasuryFeeDivisorAtReveal?.toString() ?? null; +const stateDivisor = (harness) => harness.state()?.depositTreasuryFeeDivisor?.toString() ?? null; + +test("Initialized(1) seeds the first reveal with the Bridge's historical 2000 divisor", async () => { + const h = await createBridgeHarness({ endOfBlockDivisor: 500 }); + h.initialize(); + const deposit = h.reveal({ fee: 5 }); + assert.equal(divisor(deposit), "2000"); + assert.equal(deposit.treasuryFee.toString(), "5"); + assert.equal(deposit.status, "REVEALED"); + assert.equal(deposit.transactions.length, 1); + assert.equal(h.stats().numDeposits, 1); + assert.equal(h.parameterCalls(), 0); + assert.equal(h.warnings.length, 0); +}); + +for (const [before, after] of [[0, 500], [500, 0]]) { + test(`reveal/update/reveal in one block preserves divisor ${before} then ${after}`, async () => { + const h = await createBridgeHarness({ endOfBlockDivisor: after }); + h.initialize(); + h.update(before); + const first = h.reveal(); + h.update(after); + const second = h.reveal(); + assert.equal(divisor(first), String(before)); + assert.equal(divisor(second), String(after)); + assert.equal(divisor(h.deposit(first.id)), String(before), "later updates cannot rewrite the earlier deposit"); + assert.equal(first.treasuryFee.toString(), "0"); + assert.equal(second.treasuryFee.toString(), "0"); + assert.equal(stateDivisor(h), String(after)); + assert.equal(h.parameterCalls(), 0); + }); +} + +test("successive updates apply before each reveal and retain earlier deposit snapshots", async () => { + const h = await createBridgeHarness({ endOfBlockDivisor: 250 }); + h.initialize(); + h.update(0); + h.update(500); + const charged = h.reveal({ fee: 20 }); + const waived = h.reveal(); + h.update(1000); + const next = h.reveal({ fee: 10 }); + h.update(250); + assert.equal(divisor(charged), "500"); + assert.equal(divisor(waived), "500"); + assert.equal(divisor(next), "1000"); + assert.equal(charged.treasuryFee.toString(), "20"); + assert.equal(waived.treasuryFee.toString(), "0"); + assert.equal(next.treasuryFee.toString(), "10"); + assert.equal(divisor(h.deposit(charged.id)), "500"); + assert.equal(stateDivisor(h), "250"); + assert.equal(h.parameterCalls(), 0); +}); + +test("missing initialization stays unknown until an event supplies the divisor", async () => { + const h = await createBridgeHarness(); + const unknown = h.reveal(); + assert.equal(divisor(unknown), null); + assert.equal(stateDivisor(h), null); + assert.equal(h.warnings.length, 1); + h.initialize(2); + assert.equal(divisor(h.reveal()), null, "a later reinitializer does not imply a historical default"); + h.update(0); + assert.equal(divisor(h.reveal()), "0", "known zero differs from missing history"); + assert.equal(divisor(h.deposit(unknown.id)), null, "new state cannot backfill an unknown earlier reveal"); + assert.equal(h.parameterCalls(), 0); +}); + +for (const value of [0, 500]) { + test(`initialization and reinitialization preserve an already tracked divisor of ${value}`, async () => { + const h = await createBridgeHarness(); + h.update(value); + h.initialize(); + h.initialize(2); + assert.equal(stateDivisor(h), String(value)); + assert.equal(divisor(h.reveal()), String(value)); + }); +} + +test("parameter tracking preserves unrelated BridgeState fields", async () => { + const h = await createBridgeHarness(); + const router = h.bytes(42, 20); + h.handlers.handleLifecycleRouterSet(h.event({ lifecycleRouter: router })); + h.handlers.handleNewWalletSchemeSet(h.event({ scheme: 1 })); + h.initialize(); + assert.equal(stateDivisor(h), "2000", "seed an existing singleton whose divisor is unknown"); + h.update(500); + h.initialize(2); + const state = h.state(); + assert.equal(state.currentScheme, "FROST"); + assert.equal(state.lifecycleRouter.toHexString(), router.toHexString()); + assert.equal(stateDivisor(h), "500"); +}); + +test("an unreadable legacy deposit record does not prevent the historical divisor snapshot", async () => { + const h = await createBridgeHarness(); + h.initialize(); + const deposit = h.reveal({ reverted: true }); + assert.equal(divisor(deposit), "2000"); + assert.equal(deposit.treasuryFee.toString(), "0"); + assert.equal(h.warnings.length, 1); + assert.equal(h.parameterCalls(), 0); +}); diff --git a/src/mappingBridge.ts b/src/mappingBridge.ts index 2f02a97..b9b0be2 100644 --- a/src/mappingBridge.ts +++ b/src/mappingBridge.ts @@ -78,9 +78,7 @@ function deriveLegacyWalletID(walletPubKeyHash: Bytes): Bytes { const SCHEME_ECDSA = "ECDSA" const SCHEME_FROST = "FROST" -// Singleton BridgeState id; the entity tracks the post-#431/#434/ -// #435/#439 governance state that doesn't map cleanly to per-wallet -// records. +// Singleton BridgeState id for governance state shared across deposits and wallets. const BRIDGE_STATE_ID = "singleton" function getOrCreateBridgeState(): BridgeState { @@ -135,6 +133,9 @@ function saveWalletRegistration( export function handleDepositParametersUpdated( event: DepositParametersUpdated ): void { + let state = getOrCreateBridgeState() + state.depositTreasuryFeeDivisor = event.params.depositTreasuryFeeDivisor + state.save() } export function handleDepositRevealed(event: DepositRevealed): void { @@ -177,6 +178,17 @@ export function handleDepositRevealed(event: DepositRevealed): void { } else { deposit.treasuryFee = depositsCall.value.treasuryFee } + // Snapshot the state at this event. An eth_call reads end-of-block state, + // which may include a governance update AFTER this reveal in the same block. + let state = getOrCreateBridgeState() + deposit.treasuryFeeDivisorAtReveal = state.depositTreasuryFeeDivisor + if (state.depositTreasuryFeeDivisor === null) { + log.warning( + "handleDepositRevealed: deposit treasury fee divisor is unseeded at block {}; leaving treasuryFeeDivisorAtReveal null", + [event.block.number.toString()] + ) + } + deposit.walletPubKeyHash = event.params.walletPubKeyHash deposit.fundingTxHash = event.params.fundingTxHash deposit.fundingOutputIndex = event.params.fundingOutputIndex @@ -238,6 +250,18 @@ export function handleGovernanceTransferred( } export function handleInitialized(event: Initialized): void { + // Bridge.initialize sets 2000 without a DepositParametersUpdated event. + // Both configured networks start at the proxy deployment block, including + // Initialized(1). Later governance updates are replayed in event order. + // Reinitializers must not reset the divisor. See docs/treasury-fee-divisor.md. + if (event.params.version != 1) { + return + } + let state = getOrCreateBridgeState() + if (state.depositTreasuryFeeDivisor === null) { + state.depositTreasuryFeeDivisor = BigInt.fromI32(2000) + state.save() + } } export function handleMovedFundsSweepTimedOut( diff --git a/subgraph.yaml b/subgraph.yaml index 299f3a7..3aa06b6 100644 --- a/subgraph.yaml +++ b/subgraph.yaml @@ -18,6 +18,7 @@ dataSources: entities: - Deposit - Redemption + - BridgeState abis: - name: Bridge file: ./abis/Bridge.json