diff --git a/src/dataworker/DataworkerConfig.ts b/src/dataworker/DataworkerConfig.ts index 0009831d5f..3605f0651a 100644 --- a/src/dataworker/DataworkerConfig.ts +++ b/src/dataworker/DataworkerConfig.ts @@ -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; @@ -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, @@ -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" }); @@ -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 diff --git a/src/dataworker/Disputer.ts b/src/dataworker/Disputer.ts index d6afdb4445..ff03d56ac2 100644 --- a/src/dataworker/Disputer.ts +++ b/src/dataworker/Disputer.ts @@ -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; @@ -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; @@ -55,30 +66,52 @@ export class Disputer { async validate(): Promise { 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(); } } diff --git a/src/dataworker/README.md b/src/dataworker/README.md index 8dc1ae7dfd..2623c63cb0 100644 --- a/src/dataworker/README.md +++ b/src/dataworker/README.md @@ -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. \ No newline at end of file +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. diff --git a/src/dataworker/index.ts b/src/dataworker/index.ts index c1b409caed..66f83e513e 100644 --- a/src/dataworker/index.ts +++ b/src/dataworker/index.ts @@ -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" }); diff --git a/src/utils/SDKUtils.ts b/src/utils/SDKUtils.ts index 5ca5cdb081..60e18e2c1e 100644 --- a/src/utils/SDKUtils.ts +++ b/src/utils/SDKUtils.ts @@ -58,6 +58,7 @@ export const { getCurrentTime, bnZero, bnOne, + bnMax, bnUint32Max, bnUint256Max, chainIsOPStack, diff --git a/test/disputer.watchdog.ts b/test/disputer.watchdog.ts index 10ff5a83b3..eeb067896a 100644 --- a/test/disputer.watchdog.ts +++ b/test/disputer.watchdog.ts @@ -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;