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
8 changes: 6 additions & 2 deletions .github/workflows/ci-checks.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
37 changes: 37 additions & 0 deletions docs/treasury-fee-divisor.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`.
Expand Down
176 changes: 176 additions & 0 deletions scripts/bridge-mapping-harness.mjs
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
108 changes: 108 additions & 0 deletions scripts/check-deposit-divisor.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading