Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
451 lines
22 KiB
Solidity
451 lines
22 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
|
|
interface IAereSinkSettlement {
|
|
function flush(address token, uint256 amount) external;
|
|
}
|
|
|
|
/**
|
|
* @title AereSettlementHubV2 - institutional cross-asset settlement venue
|
|
* @notice Counterparties deposit tokenised collateral (BUIDL, USDY, OUSG,
|
|
* LBTC, SolvBTC, pumpBTC, USDC.e, etc.) for batched solver-driven
|
|
* settlement on AERE Chain 2800. The hub enforces:
|
|
* - per-asset configuration (bond, perTxLimit, paused, decimals)
|
|
* - solver allowlist (Phase 1) then bonded permissionless (Phase 2)
|
|
* - 50bps protocol fee, immutable, routed to AereSink
|
|
* - RWA receiver allowlist for restricted assets
|
|
* - 6-hour challenge window on settlement claims
|
|
*
|
|
* @dev CORRECTED FORK of AereSettlementHub (canonical
|
|
* 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02). This is a FRESH INERT
|
|
* redeploy (deployer-owned, holds nothing, not wired live). It fixes
|
|
* two audited defects in the V1 logic:
|
|
*
|
|
* F-SWEEP (HIGH griefing) - V1 sweepResidual() was permissionless and
|
|
* took an arbitrary amount with no residual accounting, so any EOA
|
|
* could push a live intent's parked settlement funds and posted
|
|
* solver bonds into the sink, making settle() revert TransferFailed
|
|
* and permanently bricking the settlement (depositor funds burned,
|
|
* solver unpaid). V2 tracks a per-asset committed-liabilities total
|
|
* (parked intent remainders + posted bonds) and gates sweepResidual
|
|
* to the TRUE residual only: balance minus committed. A permissionless
|
|
* sweep can therefore only ever take genuine surplus (fees or slashed
|
|
* bonds that could not reach the sink) and can never touch funds owed
|
|
* to a solver or a depositor.
|
|
*
|
|
* F-LOCK (liveness) - V1 had no refund/cancel/timeout, so an unclaimed
|
|
* deposit was locked forever. V2 records the deposit timestamp and
|
|
* adds cancelIntent(), a depositor-only refund path for an intent
|
|
* that no solver ever claimed, callable after REFUND_TIMEOUT. It
|
|
* returns the parked remainder to the depositor and closes the intent.
|
|
*
|
|
* AereSink wiring is in the constructor and IMMUTABLE. Fee bps is
|
|
* constant 50.
|
|
*/
|
|
contract AereSettlementHubV2 is Ownable, ReentrancyGuard {
|
|
|
|
/* ------------------------------- immutable ------------------------------- */
|
|
|
|
address public immutable SINK; // AereSink (immutable)
|
|
uint16 public constant PROTOCOL_FEE_BPS = 50;
|
|
uint256 public constant CHALLENGE_WINDOW = 6 hours;
|
|
|
|
/// @notice How long after deposit an unclaimed intent may be refunded by
|
|
/// its depositor. Generous so a legitimate in-flight settlement is
|
|
/// never at risk; a solver claims long before this elapses.
|
|
uint256 public constant REFUND_TIMEOUT = 7 days;
|
|
|
|
/* --------------------------------- structs ------------------------------ */
|
|
|
|
struct AssetConfig {
|
|
bool listed;
|
|
bool paused;
|
|
uint8 decimals;
|
|
uint256 minBond; // bond required to claim this asset
|
|
uint256 perTxLimit; // max single-deposit amount, 0 = no cap
|
|
bool restrictedRecipient; // if true, recipient must be on allowlist
|
|
}
|
|
|
|
struct Intent {
|
|
address depositor;
|
|
address asset;
|
|
uint256 amount; // post-fee solverPortion (parked remainder)
|
|
address recipient;
|
|
bool open; // true while the intent is live; false once terminal
|
|
bool settled; // true once released to the solver
|
|
address solver;
|
|
uint64 claimedAt;
|
|
uint64 createdAt; // deposit timestamp, drives the refund timeout
|
|
bytes fillEvidence;
|
|
uint256 bondLocked; // solver bond reserved at claim, released at settle
|
|
}
|
|
|
|
/* --------------------------------- state -------------------------------- */
|
|
|
|
/// @notice asset then config
|
|
mapping(address => AssetConfig) public assetConfig;
|
|
|
|
/// @notice asset then allowed recipient then true
|
|
mapping(address => mapping(address => bool)) public recipientAllowed;
|
|
|
|
/// @notice intentId then Intent
|
|
mapping(bytes32 => Intent) public intents;
|
|
|
|
/// @notice solver allowlist per asset (Phase-1). Phase-2 will switch
|
|
/// to bond-only permissionless gating.
|
|
mapping(address => mapping(address => bool)) public solverAllowed; // asset then solver then bool
|
|
|
|
/// @notice solver bonds, per (asset, solver).
|
|
mapping(address => mapping(address => uint256)) public solverBond;
|
|
|
|
/// @notice Portion of a solver's bond currently RESERVED to back its own
|
|
/// outstanding claimed-but-unsettled intents, per (asset, solver).
|
|
/// At claim() we lock the asset's minBond; at settle() we release the
|
|
/// exact amount locked for that intent. A solver may only withdraw the
|
|
/// FREE remainder (solverBond - lockedBond), so a claimed intent's
|
|
/// backing stays slashable through settlement and a solver can never
|
|
/// withdraw to dodge an imminent slash.
|
|
mapping(address => mapping(address => uint256)) public lockedBond;
|
|
|
|
/// @notice Per-asset committed liabilities: the exact amount the hub OWES,
|
|
/// equal to the sum of every open intent's parked remainder plus
|
|
/// every posted solver bond for that asset. Maintained in lockstep
|
|
/// with deposit / bond / settle / cancel / slash. The genuine
|
|
/// residual sweepResidual may touch is balanceOf(hub) minus this.
|
|
mapping(address => uint256) public committedLiabilities;
|
|
|
|
/* --------------------------------- events ------------------------------- */
|
|
|
|
event AssetListed(address indexed asset, uint8 decimals, uint256 minBond, uint256 perTxLimit, bool restrictedRecipient);
|
|
event AssetPausedSet(address indexed asset, bool paused);
|
|
event RecipientAllowedSet(address indexed asset, address indexed recipient, bool allowed);
|
|
event SolverAllowedSet(address indexed asset, address indexed solver, bool allowed);
|
|
|
|
event Deposited(bytes32 indexed intentId, address indexed depositor, address indexed asset, uint256 amountAfterFee, uint256 feeAmount, address recipient);
|
|
event Claimed(bytes32 indexed intentId, address indexed solver, uint64 claimedAt);
|
|
event Settled(bytes32 indexed intentId, address indexed solver, uint256 amount);
|
|
event Challenged(bytes32 indexed intentId, address indexed challenger, string reason);
|
|
event Refunded(bytes32 indexed intentId, address indexed depositor, uint256 amount);
|
|
event SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount);
|
|
event SolverBondWithdrawn(address indexed asset, address indexed solver, uint256 amount);
|
|
event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount);
|
|
event ResidualSwept(address indexed asset, uint256 amount, address indexed caller);
|
|
|
|
/* --------------------------------- errors ------------------------------- */
|
|
|
|
error ZeroAddress();
|
|
error ZeroAmount();
|
|
error AssetNotListed();
|
|
error AssetPausedErr();
|
|
error PerTxLimitExceeded(uint256 amount, uint256 limit);
|
|
error RecipientNotAllowed();
|
|
error NotAllowedSolver();
|
|
error InsufficientBond(uint256 have, uint256 need);
|
|
error InsufficientFreeBond(uint256 free, uint256 requested);
|
|
error IntentUnknown();
|
|
error IntentAlreadyClaimed();
|
|
error IntentAlreadySettled();
|
|
error WithinChallengeWindow();
|
|
error ChallengeWindowElapsed();
|
|
error TransferFailed();
|
|
error DuplicateIntent();
|
|
error NotDepositor();
|
|
error RefundTimeoutNotReached(uint256 nowTs, uint256 unlockTs);
|
|
error ExceedsResidual(uint256 amount, uint256 residual);
|
|
|
|
/* ------------------------------- constructor ---------------------------- */
|
|
|
|
constructor(address sink) {
|
|
if (sink == address(0)) revert ZeroAddress();
|
|
SINK = sink;
|
|
}
|
|
|
|
/* ------------------------------- asset admin ---------------------------- */
|
|
|
|
function listAsset(
|
|
address asset,
|
|
uint8 decimals,
|
|
uint256 minBond,
|
|
uint256 perTxLimit,
|
|
bool restrictedRecipient
|
|
) external onlyOwner {
|
|
if (asset == address(0)) revert ZeroAddress();
|
|
assetConfig[asset] = AssetConfig({
|
|
listed: true,
|
|
paused: false,
|
|
decimals: decimals,
|
|
minBond: minBond,
|
|
perTxLimit: perTxLimit,
|
|
restrictedRecipient: restrictedRecipient
|
|
});
|
|
emit AssetListed(asset, decimals, minBond, perTxLimit, restrictedRecipient);
|
|
}
|
|
|
|
function setAssetPaused(address asset, bool paused) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
assetConfig[asset].paused = paused;
|
|
emit AssetPausedSet(asset, paused);
|
|
}
|
|
|
|
function setRecipientAllowed(address asset, address recipient, bool allowed) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
recipientAllowed[asset][recipient] = allowed;
|
|
emit RecipientAllowedSet(asset, recipient, allowed);
|
|
}
|
|
|
|
/* ------------------------------ solver admin ---------------------------- */
|
|
|
|
function setSolverAllowed(address asset, address solver, bool allowed) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
if (solver == address(0)) revert ZeroAddress();
|
|
solverAllowed[asset][solver] = allowed;
|
|
emit SolverAllowedSet(asset, solver, allowed);
|
|
}
|
|
|
|
/// @notice Permissionless solver bond top-up. The posted bond becomes a
|
|
/// committed liability so sweepResidual can never touch it.
|
|
function depositSolverBond(address asset, address solver, uint256 amount) external nonReentrant {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
if (amount == 0) revert ZeroAmount();
|
|
bool ok = IERC20(asset).transferFrom(msg.sender, address(this), amount);
|
|
if (!ok) revert TransferFailed();
|
|
solverBond[asset][solver] += amount;
|
|
committedLiabilities[asset] += amount;
|
|
emit SolverBondDeposited(asset, solver, amount);
|
|
}
|
|
|
|
/// @notice Solver-callable withdrawal of its OWN FREE bond. This is the F-BOND
|
|
/// fix: V2 previously had no path to return an honest bond, so a
|
|
/// solver's posted bond was permanently locked (only slashSolverBond,
|
|
/// owner-only, could ever reduce it, and that sends to the sink not the
|
|
/// solver). Now a solver reclaims the portion of its bond that is NOT
|
|
/// reserved to back an outstanding claimed-but-unsettled intent.
|
|
///
|
|
/// @dev Guarantees:
|
|
/// - only msg.sender's own bond is ever touched (ownership);
|
|
/// - only the FREE bond (solverBond - lockedBond) is withdrawable, so
|
|
/// a claimed intent's minBond backing stays slashable until settle
|
|
/// and a solver cannot dodge an imminent slash;
|
|
/// - solverBond and committedLiabilities decrement in lockstep with
|
|
/// the outbound transfer, preserving the sweep-reserve invariant
|
|
/// (balance >= committed) so sweepResidual can never reach a bond;
|
|
/// - nonReentrant.
|
|
function withdrawSolverBond(address asset, uint256 amount) external nonReentrant {
|
|
if (amount == 0) revert ZeroAmount();
|
|
uint256 bonded = solverBond[asset][msg.sender];
|
|
uint256 locked = lockedBond[asset][msg.sender];
|
|
uint256 free = bonded > locked ? bonded - locked : 0;
|
|
if (amount > free) revert InsufficientFreeBond(free, amount);
|
|
|
|
// Decrement bookkeeping BEFORE the transfer; both drop in lockstep so the
|
|
// committed reserve stays exactly balance-backed.
|
|
solverBond[asset][msg.sender] = bonded - amount;
|
|
committedLiabilities[asset] -= amount;
|
|
|
|
bool ok = IERC20(asset).transfer(msg.sender, amount);
|
|
if (!ok) revert TransferFailed();
|
|
emit SolverBondWithdrawn(asset, msg.sender, amount);
|
|
}
|
|
|
|
/// @notice Owner slashes a solver bond. The destination is the immutable SINK
|
|
/// and the owner cannot redirect it, but delivery is BEST EFFORT: the
|
|
/// flush below is a low-level call whose failure is swallowed, so a
|
|
/// successful slash does NOT prove the amount reached the sink.
|
|
function slashSolverBond(address asset, address solver, uint256 amount) external onlyOwner nonReentrant {
|
|
uint256 cur = solverBond[asset][solver];
|
|
if (cur < amount) revert InsufficientBond(cur, amount);
|
|
solverBond[asset][solver] = cur - amount;
|
|
// The slashed bond is no longer owed to the solver, so it stops being a
|
|
// committed liability. It is destined for the sink.
|
|
committedLiabilities[asset] -= amount;
|
|
// Best-effort push to the immutable SINK; see the failure branch below.
|
|
IERC20(asset).approve(SINK, amount);
|
|
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
|
|
if (!ok) {
|
|
// Sink couldn't accept (e.g. no swap route for an exotic asset). The
|
|
// tokens remain in the hub as GENUINE residual (no longer committed),
|
|
// which permissionless sweepResidual can later re-flush. Clear the
|
|
// dangling approval so it cannot be reused.
|
|
IERC20(asset).approve(SINK, 0);
|
|
}
|
|
emit SolverBondSlashed(asset, solver, amount);
|
|
}
|
|
|
|
/* --------------------------------- core --------------------------------- */
|
|
|
|
function deposit(
|
|
address asset,
|
|
uint256 amount,
|
|
bytes32 intentId,
|
|
address recipient
|
|
) external nonReentrant returns (uint256 amountAfterFee) {
|
|
AssetConfig memory cfg = assetConfig[asset];
|
|
if (!cfg.listed) revert AssetNotListed();
|
|
if (cfg.paused) revert AssetPausedErr();
|
|
if (amount == 0) revert ZeroAmount();
|
|
if (recipient == address(0)) revert ZeroAddress();
|
|
if (cfg.perTxLimit != 0 && amount > cfg.perTxLimit) revert PerTxLimitExceeded(amount, cfg.perTxLimit);
|
|
if (cfg.restrictedRecipient && !recipientAllowed[asset][recipient]) revert RecipientNotAllowed();
|
|
if (intents[intentId].depositor != address(0)) revert DuplicateIntent();
|
|
|
|
uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000;
|
|
amountAfterFee = amount - fee;
|
|
|
|
// Pull full amount.
|
|
bool pulled = IERC20(asset).transferFrom(msg.sender, address(this), amount);
|
|
if (!pulled) revert TransferFailed();
|
|
|
|
// Route the fee to the sink. If the sink rejects (unsupported asset),
|
|
// the fee stays in the contract as GENUINE residual (never committed)
|
|
// and sweepResidual handles it.
|
|
if (fee > 0) {
|
|
IERC20(asset).approve(SINK, fee);
|
|
(bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee));
|
|
if (!sinkOk) {
|
|
// Clear the dangling approval; the fee is now genuine residual.
|
|
IERC20(asset).approve(SINK, 0);
|
|
}
|
|
}
|
|
|
|
// The parked remainder is owed to whichever solver settles this intent
|
|
// (or refunded to the depositor on timeout): it is a committed liability.
|
|
committedLiabilities[asset] += amountAfterFee;
|
|
|
|
intents[intentId] = Intent({
|
|
depositor: msg.sender,
|
|
asset: asset,
|
|
amount: amountAfterFee,
|
|
recipient: recipient,
|
|
open: true,
|
|
settled: false,
|
|
solver: address(0),
|
|
claimedAt: 0,
|
|
createdAt: uint64(block.timestamp),
|
|
fillEvidence: "",
|
|
bondLocked: 0
|
|
});
|
|
|
|
emit Deposited(intentId, msg.sender, asset, amountAfterFee, fee, recipient);
|
|
}
|
|
|
|
function claim(bytes32 intentId, bytes calldata fillEvidence) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt != 0) revert IntentAlreadyClaimed();
|
|
if (!solverAllowed[i.asset][msg.sender]) revert NotAllowedSolver();
|
|
|
|
AssetConfig memory cfg = assetConfig[i.asset];
|
|
uint256 bond = solverBond[i.asset][msg.sender];
|
|
// The solver must have at least minBond FREE (not already reserved to back
|
|
// another of its outstanding claimed intents). Reserving minBond here keeps
|
|
// this intent's backing slashable until it settles, so a solver cannot
|
|
// withdrawSolverBond to escape a slash of a claimed-but-unsettled intent.
|
|
uint256 lockedNow = lockedBond[i.asset][msg.sender];
|
|
// Defensive against a prior slash having pushed the bond below the locked
|
|
// amount; treat a shortfall as zero free bond rather than underflowing.
|
|
uint256 free = bond > lockedNow ? bond - lockedNow : 0;
|
|
if (free < cfg.minBond) revert InsufficientBond(free, cfg.minBond);
|
|
|
|
lockedBond[i.asset][msg.sender] += cfg.minBond;
|
|
i.bondLocked = cfg.minBond;
|
|
i.solver = msg.sender;
|
|
i.claimedAt = uint64(block.timestamp);
|
|
i.fillEvidence = fillEvidence;
|
|
emit Claimed(intentId, msg.sender, uint64(block.timestamp));
|
|
}
|
|
|
|
function challenge(bytes32 intentId, string calldata reason) external {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt == 0) revert IntentUnknown();
|
|
if (block.timestamp >= uint256(i.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowElapsed();
|
|
emit Challenged(intentId, msg.sender, reason);
|
|
}
|
|
|
|
function settle(bytes32 intentId) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt == 0) revert IntentUnknown();
|
|
if (block.timestamp < uint256(i.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow();
|
|
|
|
i.settled = true;
|
|
uint256 amt = i.amount;
|
|
// The parked remainder leaves to the solver, so it is no longer committed.
|
|
committedLiabilities[i.asset] -= amt;
|
|
// Release the bond this intent reserved at claim: it is now free to withdraw
|
|
// (or to keep backing other claims). Guard against underflow if the bond was
|
|
// slashed below the locked amount in the interim.
|
|
uint256 bl = i.bondLocked;
|
|
if (bl > 0) {
|
|
i.bondLocked = 0;
|
|
uint256 lb = lockedBond[i.asset][i.solver];
|
|
lockedBond[i.asset][i.solver] = lb > bl ? lb - bl : 0;
|
|
}
|
|
bool ok = IERC20(i.asset).transfer(i.solver, amt);
|
|
if (!ok) revert TransferFailed();
|
|
emit Settled(intentId, i.solver, amt);
|
|
}
|
|
|
|
/// @notice Depositor refund for an intent that no solver ever claimed.
|
|
/// Callable only by the depositor, only once REFUND_TIMEOUT has
|
|
/// elapsed since deposit, and only while the intent is unclaimed and
|
|
/// unsettled. Returns the parked remainder to the depositor and
|
|
/// closes the intent. This is the F-LOCK liveness fix: a never-claimed
|
|
/// deposit can no longer be trapped forever.
|
|
function cancelIntent(bytes32 intentId) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt != 0) revert IntentAlreadyClaimed();
|
|
if (msg.sender != i.depositor) revert NotDepositor();
|
|
uint256 unlockTs = uint256(i.createdAt) + REFUND_TIMEOUT;
|
|
if (block.timestamp < unlockTs) revert RefundTimeoutNotReached(block.timestamp, unlockTs);
|
|
|
|
i.open = false;
|
|
uint256 amt = i.amount;
|
|
// The parked remainder returns to the depositor, so it is no longer committed.
|
|
committedLiabilities[i.asset] -= amt;
|
|
bool ok = IERC20(i.asset).transfer(i.depositor, amt);
|
|
if (!ok) revert TransferFailed();
|
|
emit Refunded(intentId, i.depositor, amt);
|
|
}
|
|
|
|
/* ----------------------------- dust / residual -------------------------- */
|
|
|
|
/// @notice Genuine residual for an asset: hub balance minus committed
|
|
/// liabilities (parked intent remainders + posted bonds). This is
|
|
/// the only value sweepResidual may ever move.
|
|
function residualOf(address asset) public view returns (uint256) {
|
|
uint256 bal = IERC20(asset).balanceOf(address(this));
|
|
uint256 committed = committedLiabilities[asset];
|
|
// committed can never exceed the balance under correct accounting; guard
|
|
// against underflow defensively.
|
|
return bal > committed ? bal - committed : 0;
|
|
}
|
|
|
|
/// @notice Anyone may sweep GENUINE residual balances (fees or slashed bonds
|
|
/// that could not reach the sink on their first try) by re-attempting
|
|
/// the flush. Permissionless, but strictly bounded to residualOf(asset)
|
|
/// so it can NEVER touch a live intent's parked funds or a posted
|
|
/// solver bond. This is the F-SWEEP griefing fix.
|
|
function sweepResidual(address asset, uint256 amount) external nonReentrant {
|
|
if (amount == 0) revert ZeroAmount();
|
|
uint256 residual = residualOf(asset);
|
|
if (amount > residual) revert ExceedsResidual(amount, residual);
|
|
IERC20(asset).approve(SINK, amount);
|
|
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
|
|
if (!ok) {
|
|
IERC20(asset).approve(SINK, 0);
|
|
revert TransferFailed();
|
|
}
|
|
emit ResidualSwept(asset, amount, msg.sender);
|
|
}
|
|
}
|