Skip to content
Open
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
23 changes: 22 additions & 1 deletion src/dataworker/DataworkerConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommonConfig, ProcessEnv } from "../common";
import { assert, getArweaveJWKSigner, parseJson, isDefined } from "../utils";
import { assert, BigNumber, getArweaveJWKSigner, parseJson, isDefined, toBNWei } from "../utils";

export class DataworkerConfig extends CommonConfig {
readonly minChallengeLeadTime: number;
Expand Down Expand Up @@ -41,6 +41,15 @@ export class DataworkerConfig extends CommonConfig {
// Used to instruct the dataworker to load data from arweave in times when doing so is optional (i.e. execution).
readonly loadArweaveData: boolean | undefined;

// Disputer bond maintenance levels, denominated in whole bond tokens (e.g. "15" = 15 ABT).
// When set and the disputer is enabled, a bond token balance below the trigger is topped up to
// the target by wrapping native token. Set the trigger at or above any external balance alerting
// thresholds so the top-up lands before alerts fire. Unset: no maintenance in the disputer flow;
// the disputer watchdog retains its own last-resort defaults as multiples of the bond amount.
// Levels below the HubPool bond amount are floored at the bond amount at runtime.
readonly bondBalanceTrigger?: BigNumber;
readonly bondBalanceTarget?: BigNumber;

constructor(env: ProcessEnv) {
const {
MAX_POOL_REBALANCE_LEAF_SIZE_OVERRIDE,
Expand All @@ -61,6 +70,8 @@ export class DataworkerConfig extends CommonConfig {
AWAIT_CHALLENGE_PERIOD = "false",
DISPUTE_COOLDOWN,
LOAD_ARWEAVE_DATA,
BOND_BALANCE_TRIGGER,
BOND_BALANCE_TARGET,
} = env;
super(env, { botIdentifier: "across-dataworker" });

Expand All @@ -69,6 +80,16 @@ export class DataworkerConfig extends CommonConfig {
this.disputeCooldown = Number(DISPUTE_COOLDOWN ?? 120);
this.loadArweaveData = isDefined(LOAD_ARWEAVE_DATA) ? LOAD_ARWEAVE_DATA === "true" : undefined;

this.bondBalanceTrigger = BOND_BALANCE_TRIGGER ? toBNWei(BOND_BALANCE_TRIGGER) : undefined;
this.bondBalanceTarget = BOND_BALANCE_TARGET ? toBNWei(BOND_BALANCE_TARGET) : undefined;
if (isDefined(this.bondBalanceTarget)) {
assert(isDefined(this.bondBalanceTrigger), "BOND_BALANCE_TARGET requires BOND_BALANCE_TRIGGER");
assert(
this.bondBalanceTarget.gte(this.bondBalanceTrigger),
"BOND_BALANCE_TARGET must be >= BOND_BALANCE_TRIGGER"
);
}

this.bufferToPropose = BUFFER_TO_PROPOSE ? Number(BUFFER_TO_PROPOSE) : (20 * 60) / 15; // 20 mins of blocks;
// Should we assert that the leaf count caps are > 0?
this.maxPoolRebalanceLeafSizeOverride = MAX_POOL_REBALANCE_LEAF_SIZE_OVERRIDE
Expand Down
59 changes: 46 additions & 13 deletions src/dataworker/Disputer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,31 @@ import { AugmentedTransaction, TransactionClient } from "../clients";
import {
assert,
BigNumber,
bnMax,
bnUint256Max,
bnZero,
formatEther,
getNetworkName,
isDefined,
toBNWei,
TransactionResponse,
submitTransaction,
Provider,
winston,
} from "../utils";

// Absolute bond token balance maintenance levels (18 decimals). When the balance drops below
// trigger, native token is wrapped to restore the balance to target. Unset values fall back to
// bondMultiplier-based defaults derived from the HubPool bond amount.
export type BondReserve = { trigger?: BigNumber; target?: BigNumber };

export class Disputer {
private _bondToken?: Contract;
protected bondAmount = bnZero;
protected bondMultiplier: { min: number; target: number };
// Native balance is never wrapped below this level; the mint/approve/dispute transactions
// themselves need gas.
protected nativeGasReserve = toBNWei("0.5");
protected provider: Provider;
protected txnClient: TransactionClient;
protected chain: string;
Expand All @@ -38,7 +48,8 @@ export class Disputer {
protected readonly logger: winston.Logger,
protected readonly hubPool: Contract,
readonly signer: Signer,
protected readonly simulate = true
protected readonly simulate = true,
protected readonly bondReserve: BondReserve = {}
) {
this.chain = getNetworkName(chainId);
this.provider = hubPool.provider;
Expand All @@ -55,30 +66,52 @@ export class Disputer {
async validate(): Promise<void> {
await this._getOrCreateInitPromise();

const { bondAmount, logger } = this;
const minBondAmount = bondAmount.mul(this.bondMultiplier.min);
const { bondAmount, bondReserve, logger } = this;
let minBondAmount = bondReserve.trigger ?? bondAmount.mul(this.bondMultiplier.min);
let targetBondAmount = bondReserve.target ?? bondAmount.mul(this.bondMultiplier.target);

// disputeRootBundle() pulls bondAmount from the disputer, so maintaining a balance or
// allowance below it would let validate() pass while the dispute itself reverts. Floor both
// levels at bondAmount; it is only known post-init and can change via HubPool.setBond(), so
// this cannot be enforced at config parse time.
if (minBondAmount.lt(bondAmount)) {
logger.warn({
at: "Disputer::validate",
message: "Configured bond balance trigger is below the HubPool bond amount; flooring at the bond amount.",
trigger: formatEther(minBondAmount),
bondAmount: formatEther(bondAmount),
});
minBondAmount = bondAmount;
}
if (targetBondAmount.lt(minBondAmount)) {
targetBondAmount = minBondAmount;
}

// Balance checks.
// Balance checks. Top up to target when the balance drops below the trigger, wrapping as much
// of any shortfall as the native balance permits.
const balance = await this.balance();
const mintAmount = minBondAmount.sub(balance);
if (mintAmount.gt(bnZero)) {
if (balance.lt(minBondAmount)) {
const nativeBalance = await this.provider.getBalance(await this.signer.getAddress());
if (nativeBalance.gt(mintAmount)) {
const shortfall = targetBondAmount.sub(balance);
// The native balance may already be below the gas reserve; clamp at zero.
const available = bnMax(nativeBalance.sub(this.nativeGasReserve), bnZero);
const mintAmount = shortfall.lte(available) ? shortfall : available;
if (mintAmount.gt(bnZero)) {
await this.mintBond(mintAmount);
} else {
const fmtAmount = formatEther(mintAmount);
}
if (mintAmount.lt(shortfall)) {
const fmtAmount = formatEther(shortfall.sub(mintAmount));
const message = `Insufficient native token balance to mint ${fmtAmount} bond tokens.`;
if (!this.simulate && balance.lt(bondAmount)) {
if (!this.simulate && balance.add(mintAmount).lt(bondAmount)) {
throw new Error(message);
}
logger.warn({ at: "Disputer::validate", message, nativeBalance, mintAmount });
logger.warn({ at: "Disputer::validate", message, nativeBalance, shortfall, minted: mintAmount });
}
}

// Ensure allowances are in place.
const allowance = await this.allowance();
const minAllowance = bondAmount.mul(this.bondMultiplier.target);
if (allowance.lt(minAllowance)) {
if (allowance.lt(targetBondAmount)) {
await this.approve();
}
}
Expand Down
22 changes: 21 additions & 1 deletion src/dataworker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,24 @@ The dataworker runtime file is index.ts. This file contains a `runDataworker()`

1. Checks if there is an existing pending root bundle. If there is one, validate it. If invalid, submit a dispute, otherwise proceed.
2. If no existing pending root bundle, construct and propose a new one.
3. If existing pending root bundle has passed its optimistic challenge liveness window, then execute it by calling functions on the `HubPool` and functions on each spoke network's `SpokePool`. Recall that each root bundle refers to a Merkle root describing the list of relayer and depositor refunds. Therefore, executing these refunds amounts to submitting Merkle leaves from this Merkle root to the HubPool/SpokePool and letting those contracts send out payments based on those Merkle leaf contents.
3. If existing pending root bundle has passed its optimistic challenge liveness window, then execute it by calling functions on the `HubPool` and functions on each spoke network's `SpokePool`. Recall that each root bundle refers to a Merkle root describing the list of relayer and depositor refunds. Therefore, executing these refunds amounts to submitting Merkle leaves from this Merkle root to the HubPool/SpokePool and letting those contracts send out payments based on those Merkle leaf contents.
## Bond token balance maintenance

`Disputer.validate()` maintains the signer's HubPool bond token (ABT) balance: when the balance
drops below a trigger level, it wraps native token (`bondToken.deposit()`) to restore the balance
to a target level, wrapping as much of any shortfall as the native balance allows while keeping a
small native reserve for gas.

Two runtimes invoke it:

- **Disputer** (`runDataworker()` with `DISPUTER_ENABLED`): opt-in via `BOND_BALANCE_TRIGGER` /
`BOND_BALANCE_TARGET` — absolute levels in whole bond tokens (e.g. `15` / `20`), applied before
pending root bundle validation. Set the trigger at or above any external balance-alerting
thresholds (e.g. the monitor's `MONITORED_BALANCES` warn level) so the top-up lands before
alerts fire. Unset: no maintenance in this flow.
- **Disputer watchdog** (`runDisputerWatchdog()`): always runs with last-resort defaults of
4x (trigger) and 8x (target) the HubPool bond amount, guaranteeing the watchdog can post a
dispute bond even if the disputer's maintenance has not run.

In both flows, `Disputer.validate()` floors the trigger and target at the HubPool bond amount,
since a dispute pulls the full bond amount from the disputer.
18 changes: 18 additions & 0 deletions src/dataworker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,24 @@ export async function runDataworker(_logger: winston.Logger, baseSigner: Signer)
);
// Validate and dispute pending proposal before proposing a new one
if (config.disputerEnabled) {
// Maintain the bond token reserve ahead of any dispute. Opt-in via BOND_BALANCE_TRIGGER so
// that other DISPUTER_ENABLED deployments are unaffected.
if (isDefined(config.bondBalanceTrigger)) {
const disputer = new Disputer(
config.hubPoolChainId,
logger,
clients.hubPoolClient.hubPool,
baseSigner,
!config.sendingTransactionsEnabled,
{ trigger: config.bondBalanceTrigger, target: config.bondBalanceTarget }
);
try {
await disputer.validate();
} catch (error) {
// A failed top-up shouldn't block bundle validation; a dispute without bond fails on its own.
logger.error({ at: "Dataworker#index", message: "Failed to maintain bond token reserve.", error });
}
}
await dataworker.validatePendingRootBundle(spokePoolClients, fromBlocks);
} else {
logger[startupLogLevel(config)]({ at: "Dataworker#index", message: "Disputer disabled" });
Expand Down
1 change: 1 addition & 0 deletions src/utils/SDKUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const {
getCurrentTime,
bnZero,
bnOne,
bnMax,
bnUint32Max,
bnUint256Max,
chainIsOPStack,
Expand Down
85 changes: 85 additions & 0 deletions test/disputer.watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,91 @@ describe("Disputer: Watchdog", function () {
await disputer.validate();
});

it("Disputer::validate tops up to the default target", async function () {
// beforeEach validate() found a zero balance and topped up to the default target (8x bond).
// Note: HubPool.setBond() stores the configured bond amount plus the OO final fee.
const hubBond = await hubPool.bondAmount();
const balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(hubBond.mul(8))).to.be.true;

// A subsequent validate() is a no-op: balance is at target, above the trigger.
await disputer.validate();
expect((await bondToken.balanceOf(signerAddr)).eq(balance)).to.be.true;
});

it("Disputer::validate respects configured trigger and target", async function () {
const trigger = toBNWei(3);
const target = toBNWei(5);
const custom = new Disputer(chainId, logger, hubPool, signer, simulate, { trigger, target });

// Balance (8x bond from beforeEach) is above the trigger; no mint.
const initialBalance = await bondToken.balanceOf(signerAddr);
await custom.validate();
let balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(initialBalance)).to.be.true;

// Drain below the trigger; validate() restores the balance to target.
await bondToken.connect(signer).transfer(await owner.getAddress(), balance);
await custom.validate();
balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(target)).to.be.true;
});

it("Disputer::validate floors configured levels at the HubPool bond amount", async function () {
// Trigger/target below the HubPool bond amount would leave the disputer unable to fund
// disputeRootBundle(); both are floored at the bond amount.
const hubBond = await hubPool.bondAmount();
const custom = new Disputer(chainId, logger, hubPool, signer, simulate, {
trigger: hubBond.div(4),
target: hubBond.div(2),
});

let balance = await bondToken.balanceOf(signerAddr);
await bondToken.connect(signer).transfer(await owner.getAddress(), balance);
await custom.validate();
balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(hubBond)).to.be.true;
});

it("Disputer::validate wraps partially when native balance is insufficient", async function () {
const trigger = toBNWei(3);
const target = toBNWei(10);
const custom = new Disputer(chainId, logger, hubPool, signer, simulate, { trigger, target });

let balance = await bondToken.balanceOf(signerAddr);
await bondToken.connect(signer).transfer(await owner.getAddress(), balance);

const originalNativeBalance = await ethers.provider.getBalance(signerAddr);
try {
// 2.5 native available, 0.5 reserved for gas => only 2 of the 10 target can be wrapped.
await ethers.provider.send("hardhat_setBalance", [signerAddr, toBNWei("2.5").toHexString()]);
await custom.validate();
balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(toBNWei(2))).to.be.true;
} finally {
await ethers.provider.send("hardhat_setBalance", [signerAddr, originalNativeBalance.toHexString()]);
}
});

it("Disputer::validate warns without throwing when the native balance is below the gas reserve", async function () {
// Bond balance covers a dispute (>= bondAmount) but sits below the trigger, and the native
// balance is below the gas reserve so nothing can be wrapped. validate() must warn and
// proceed; the watchdog can still fund one dispute.
const hubBond = await hubPool.bondAmount();
let balance = await bondToken.balanceOf(signerAddr);
await bondToken.connect(signer).transfer(await owner.getAddress(), balance.sub(hubBond.mul(2)));

const originalNativeBalance = await ethers.provider.getBalance(signerAddr);
try {
await ethers.provider.send("hardhat_setBalance", [signerAddr, toBNWei("0.25").toHexString()]);
await disputer.validate();
balance = await bondToken.balanceOf(signerAddr);
expect(balance.eq(hubBond.mul(2))).to.be.true;
} finally {
await ethers.provider.send("hardhat_setBalance", [signerAddr, originalNativeBalance.toHexString()]);
}
});

it("Disputer::mintBond", async function () {
let balance = await bondToken.balanceOf(signerAddr);
expect(balance.gt(bnZero)).to.be.true;
Expand Down