feat: liquidity-DoS removal — commit-first peg-in (contracts) [PoC]#499
Draft
Freshenext wants to merge 13 commits into
Draft
feat: liquidity-DoS removal — commit-first peg-in (contracts) [PoC]#499Freshenext wants to merge 13 commits into
Freshenext wants to merge 13 commits into
Conversation
On-chain peg-in/peg-out configuration: fixed-floor + percentage fee model, amount-keyed confirmation tiers, deadlines/limits, time-locked setters, and immutable deployment bounds. Implements the frozen IFlyoverConfigurations. 28 Foundry tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Static BTC deposit address derived from the RSK address (versioned domain tag, reusing the powpeg redeem-script wrapping), registrationRoot running hash, permissionless deposit-gated registration. Implements frozen IPegInAddressRegistry. 15 Foundry tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enumerable registered-LP set + per-LP registration block; proportional globalSlash across eligible LPs that skips any LP inside its grace window (bootstrap-safe via NoEligibleCollateral); configurable grace window. Existing collateral behavior preserved (storage appended). 58 collateral tests passing (41 baseline + 17 new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-commits-first peg-in: LP claims a confirmed, registered peg-in by fronting RBTC from its own wallet (never contract balances); OP_RETURN SC-call support (plain vs call, reverting destination refunds without reverting the peg-in); resolvePegIn settles via the Bridge and credits the claimer fronted+fee; unclaimed valid peg-in past the registration-anchored deadline triggers globalSlash (added to ICollateralManagement); fees and confirmations sourced from FlyoverConfigurations. 100 pegin tests passing (74 baseline + 26 new); full suite 695 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rootstock bridge (0x..1000006) is a native precompile with no EVM bytecode, so a code.length guard always reverts (NoContract) when deploying against a real RSK node. Require a non-zero address instead, matching the original PegInContract which never guarded the bridge. Verified by deploying PegInAddressRegistry to regtest and deriving deterministic addresses from the live powpeg redeem script. 15 registry tests still passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2's registerAddress called the bridge's registerFastBridgeBtcTransaction, which is the one-shot peg-in SETTLEMENT that resolvePegIn uses — so deposit- gated registration would consume the peg-in and break the LP's claim (and strand funds in the registry). PoC fix: registration records the address (permissionless); the deposit is validated downstream at requestPegIn (confirmations) and resolvePegIn (the single bridge settlement). Proper read-only SPV deposit-gating (confirmations + in-contract output match) is follow-up. Found during regtest E2E integration. Registry tests updated; 15 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsumed) registerAddress now validates the deposit WITHOUT consuming the peg-in: (1) parses the serialized BTC tx outputs and requires one pays the P2SH derived for the RSK address, (2) checks the bridge VIEW getBtcTransactionConfirmations(>=1). It never calls the one-shot registerFastBridgeBtcTransaction — that settlement stays solely in PegInContract.resolvePegIn. New signature (addr, btcTxSerialized, btcBlockHash, merkleBranchPath, merkleBranchHashes). Registry suite 17, full suite 697, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getLocalConfig deployed a BridgeMock unconditionally; on a live regtest node that means contracts settle against a fake bridge. Now chainId 33 uses the Rootstock bridge precompile (0x..1000006) like getFlyoverLocalConfig already does; BridgeMock remains only for pure-EVM local testing (e.g. anvil). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step-by-step scripts (00 config + bcli, 01 derive address, 02 fund+mine, 03 advance bridge, 04 build SPV proof, 05 register, 06 requestPegIn, 07 resolvePegIn) reproducing the live regtest peg-in that succeeded on rskj 9.0.2: user receives amount-fee RBTC. README documents the flow, the big-endian proof byte-order, and findings A (claim needs a full SPV proof) and B (resolvePegIn settlement-derivation mismatch, epic EB). Worked-example values from the 2026-06-30 run are in config.env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rskj bridge has no by-hash getBtcTransactionConfirmations, so the hash-only confirmation check could never work against a real node (returns -1) — it only passed before because BridgeMock ignored its args. Fold the proven proof-based claim into requestPegIn(rskAddr, amount, btcTxHash, opReturn, btcBlockHash, merkleBranchPath, merkleBranchHashes); remove the dead hash-only stub. Confirms via the full-proof bridge path; shared _finalizeClaim keeps runtime at 24,237 B (< EIP-170). E4 suites updated to pass proof args; full suite 697 green. Validated live on regtest (rskj 9.0.2): user received amount-fee RBTC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Finding B)
The registry's deterministic address and the native fast-bridge settlement
address now agree, so resolvePegIn releases funds and the LP is reimbursed,
with NO bridge changes. Shared src/libraries/PegInDerivation.sol is the single
source of truth used by both PegInAddressRegistry and PegInContract:
derivationValue = keccak256(keccak256("FLYOVER_PEGIN_V1",rskAddr)
++ REFUND_PLACEHOLDER ++ bytes20(pegInContract) ++ LP_PLACEHOLDER),
wrapped as a PLAIN P2SH (not segwit-wrapped); _settleWithBridge passes the
identical inputs with shouldTransferToContract=true. Fixed protocol placeholders
keep the address LP-agnostic. Registry gains setPegInContract wiring. Protos
removed. 698 tests green; PegInContract 24,277 B (< EIP-170).
Proven live on regtest (rskj 9.0.2, real bridge): full cycle settles -
requestPegIn credits the user, resolvePegIn releases 1 RBTC to the LBC and
reimburses the LP; re-resolve reverts PegInAlreadyProcessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the DoS-removal planning + design docs into the repo under docs/dos-removal/ (PRD, EPICS breakdown + per-epic story/task tree, POC-FINDINGS, ARCHITECTURE.html, HANDOFF) so the contract branch carries its own design record. Fix the runbook's POC-FINDINGS link to the in-repo path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF Scorecard
Scanned Files |
Comment on lines
+680
to
+703
| function _deliver( | ||
| address rskAddr, | ||
| uint256 netAmount, | ||
| bytes calldata opReturn | ||
| ) private returns (bool callSuccess) { | ||
| (bool isCall, address destination, uint256 maxGasFee, bytes memory callData) = _parseOpReturn(opReturn); | ||
|
|
||
| if (!isCall) { | ||
| // Plain peg-in: send the net amount to the RSK address. | ||
| (bool ok,) = payable(rskAddr).call{value: netAmount}(""); | ||
| if (!ok) revert Flyover.PaymentFailed(rskAddr, netAmount, ""); | ||
| return true; | ||
| } | ||
|
|
||
| // SC-call peg-in: call the destination with callData and the net amount, capped at maxGasFee gas. | ||
| uint256 gasCap = maxGasFee == 0 ? gasleft() : maxGasFee; | ||
| (callSuccess,) = destination.call{value: netAmount, gas: gasCap}(callData); | ||
|
|
||
| if (!callSuccess) { | ||
| // Reverting destination: refund the RSK address and mark a failed call; do NOT revert the peg-in. | ||
| (bool refunded,) = payable(rskAddr).call{value: netAmount}(""); | ||
| if (!refunded) revert Flyover.PaymentFailed(rskAddr, netAmount, ""); | ||
| } | ||
| } |
Comment on lines
278
to
405
| @@ -237,6 +339,7 @@ contract CollateralManagementContract is | |||
| revert Flyover.ProviderNotRegistered(providerAddress); | |||
| } | |||
| _resignationBlockNum[providerAddress] = block.number; | |||
| _deregisterLP(providerAddress); | |||
| emit Resigned(providerAddress); | |||
| } | |||
|
|
|||
| @@ -301,6 +404,36 @@ contract CollateralManagementContract is | |||
| return _penalties; | |||
| } | |||
Comment on lines
+19
to
+488
| contract FlyoverConfigurations is | ||
| AccessControlDefaultAdminRulesUpgradeable, | ||
| IFlyoverConfigurations | ||
| { | ||
| /// @notice The version of the contract | ||
| string public constant VERSION = "1.0.0"; | ||
|
|
||
| /// @notice Percentage fee denominator: 10_000 == 100%. | ||
| uint256 public constant FEE_PERCENTAGE_DENOMINATOR = 10_000; | ||
|
|
||
| /// @notice 1 satoshi expressed in wei; fees are rounded down to a satoshi boundary. | ||
| uint256 public constant SAT_TO_WEI_CONVERSION = 10 ** 10; | ||
|
|
||
| /// @notice Identifies the flow a queued change targets. | ||
| enum Flow { PegIn, PegOut } | ||
|
|
||
| /// @notice Identifies the scalar field a queued change targets. | ||
| enum Field { | ||
| FixedFee, | ||
| PercentageFee, | ||
| PenaltyFee, | ||
| CallTime, | ||
| ExpireTime, | ||
| ExpireBlocks, | ||
| DeliveryGrace, | ||
| MinAmount, | ||
| MaxAmount | ||
| } | ||
|
|
||
| /// @notice A queued scalar change. `eta == 0` means no change is queued. | ||
| struct PendingChange { | ||
| uint256 value; | ||
| uint256 eta; | ||
| } | ||
|
|
||
| /// @notice A queued confirmation-tier change. `eta == 0` means no change is queued. | ||
| struct PendingTiers { | ||
| ConfirmationTier[] tiers; | ||
| uint256 eta; | ||
| } | ||
|
|
||
| /// @custom:storage-location erc7201:rsk.flyover.FlyoverConfigurations | ||
| struct FlyoverConfigurationsStorage { | ||
| PegConfiguration pegIn; | ||
| PegConfiguration pegOut; | ||
| // Queued scalar changes, keyed by flow then field. | ||
| mapping(Flow => mapping(Field => PendingChange)) pendingScalar; | ||
| // Queued confirmation-tier changes, keyed by flow. | ||
| mapping(Flow => PendingTiers) pendingTiers; | ||
| } | ||
|
|
||
| /// @custom:storage-location erc7201:rsk.flyover.FlyoverConfigurations.immutables | ||
| /// @dev Held in a separate namespace from the mutable config. Written once in `initialize`. | ||
| struct FlyoverConfigurationsBounds { | ||
| uint256 timelockDelay; | ||
| PegConfiguration pegInMin; | ||
| PegConfiguration pegInMax; | ||
| PegConfiguration pegOutMin; | ||
| PegConfiguration pegOutMax; | ||
| } | ||
|
|
||
| // ERC-7201: keccak256(abi.encode(uint256(keccak256("rsk.flyover.FlyoverConfigurations")) - 1)) & | ||
| // ~bytes32(uint256(0xff)) | ||
| bytes32 private constant _FLYOVER_CONFIGURATIONS_STORAGE = | ||
| 0x13aa2a37a5354fe7c5dcced2a6c33933ec66091f98f22792660cd2862f158700; | ||
|
|
||
| // ERC-7201: keccak256(abi.encode(uint256(keccak256("rsk.flyover.FlyoverConfigurations.immutables")) - 1)) & | ||
| // ~bytes32(uint256(0xff)) | ||
| bytes32 private constant _FLYOVER_CONFIGURATIONS_BOUNDS = | ||
| 0x24a0b1aa6ada62b386d00addebc5c6489a9bb8248239ea1e105451a8f65b6800; | ||
|
|
||
| /// @notice Raised when a value falls outside its immutable deployment bound. | ||
| error OutOfBounds(Flow flow, Field field, uint256 value, uint256 min, uint256 max); | ||
| /// @notice Raised when applying a change before its time lock elapses, or when no change is queued. | ||
| error TimelockNotElapsed(uint256 eta, uint256 nowTime); | ||
| /// @notice Raised when no change is queued for the targeted slot. | ||
| error NoQueuedChange(Flow flow); | ||
| /// @notice Raised when a confirmation-tier list is not sorted strictly ascending by maxAmount. | ||
| error TiersNotAscending(); | ||
| /// @notice Raised when a confirmation-tier list is empty. | ||
| error EmptyTiers(); | ||
| /// @notice Raised when expireTime would not be strictly later than callTime. | ||
| error ExpireTimeNotAfterCallTime(uint256 callTime, uint256 expireTime); | ||
|
|
||
| /// @custom:oz-upgrades-unsafe-allow constructor | ||
| constructor() { | ||
| _disableInitializers(); | ||
| } | ||
|
|
||
| /// @notice This contract does not accept value | ||
| // solhint-disable-next-line comprehensive-interface | ||
| receive() external payable { | ||
| revert Flyover.PaymentNotAllowed(); | ||
| } | ||
|
|
||
| /// @notice Initializes the contract with seed configuration and the immutable bounds. | ||
| /// @param defaultAdmin The default admin of the contract | ||
| /// @param initialDelay The initial delay for changes in the default admin role | ||
| /// @param timelockDelay The delay (seconds) every queued configuration change must wait | ||
| /// @param pegInConfig The initial peg-in configuration | ||
| /// @param pegOutConfig The initial peg-out configuration | ||
| /// @param pegInMin Lower bound for every peg-in scalar field | ||
| /// @param pegInMax Upper bound for every peg-in scalar field | ||
| /// @param pegOutMin Lower bound for every peg-out scalar field | ||
| /// @param pegOutMax Upper bound for every peg-out scalar field | ||
| // solhint-disable-next-line comprehensive-interface | ||
| function initialize( | ||
| address defaultAdmin, | ||
| uint48 initialDelay, | ||
| uint256 timelockDelay, | ||
| PegConfiguration calldata pegInConfig, | ||
| PegConfiguration calldata pegOutConfig, | ||
| PegConfiguration calldata pegInMin, | ||
| PegConfiguration calldata pegInMax, | ||
| PegConfiguration calldata pegOutMin, | ||
| PegConfiguration calldata pegOutMax | ||
| ) external initializer { | ||
| __AccessControlDefaultAdminRules_init(initialDelay, defaultAdmin); | ||
|
|
||
| FlyoverConfigurationsBounds storage b = _getBounds(); | ||
| b.timelockDelay = timelockDelay; | ||
| _copyBounds(b.pegInMin, pegInMin); | ||
| _copyBounds(b.pegInMax, pegInMax); | ||
| _copyBounds(b.pegOutMin, pegOutMin); | ||
| _copyBounds(b.pegOutMax, pegOutMax); | ||
|
|
||
| // Seed config must itself respect bounds and the callTime/expireTime invariant. | ||
| _validateWholeConfig(Flow.PegIn, pegInConfig); | ||
| _validateWholeConfig(Flow.PegOut, pegOutConfig); | ||
|
|
||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| _copyConfig($.pegIn, pegInConfig); | ||
| _copyConfig($.pegOut, pegOutConfig); | ||
| } | ||
|
|
||
| // ============================ Time-locked scalar setters ============================ | ||
|
|
||
| /// @notice Queues a change to a scalar field. Validates against immutable bounds at queue time. | ||
| /// @param flow The flow (peg-in or peg-out) to change | ||
| /// @param field The scalar field to change | ||
| /// @param value The new value | ||
| // solhint-disable-next-line comprehensive-interface | ||
| function queueChange(Flow flow, Field field, uint256 value) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| _checkBound(flow, field, value); | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| uint256 eta = block.timestamp + _getBounds().timelockDelay; | ||
| $.pendingScalar[flow][field] = PendingChange({value: value, eta: eta}); | ||
| } | ||
|
|
||
| /// @notice Applies a previously queued scalar change once its time lock has elapsed. | ||
| /// Re-validates against bounds and the callTime/expireTime invariant, then emits the | ||
| /// matching `*Changed` event with old and new values. | ||
| /// @param flow The flow to apply the change to | ||
| /// @param field The scalar field to apply | ||
| // solhint-disable-next-line comprehensive-interface | ||
| function applyChange(Flow flow, Field field) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| PendingChange memory pending = $.pendingScalar[flow][field]; | ||
| if (pending.eta == 0) revert NoQueuedChange(flow); | ||
| if (block.timestamp < pending.eta) revert TimelockNotElapsed(pending.eta, block.timestamp); | ||
|
|
||
| _checkBound(flow, field, pending.value); | ||
|
|
||
| PegConfiguration storage config = flow == Flow.PegIn ? $.pegIn : $.pegOut; | ||
| uint256 oldValue = _readField(config, field); | ||
| _checkDeadlineInvariant(config, field, pending.value); | ||
| _writeField(config, field, pending.value); | ||
|
|
||
| delete $.pendingScalar[flow][field]; | ||
| _emitChange(flow, field, oldValue, pending.value); | ||
| } | ||
|
|
||
| // ============================ Time-locked tier setter ============================ | ||
|
|
||
| /// @notice Queues a change to the confirmation-tier list. Validates ascending order at queue time. | ||
| /// @param flow The flow whose tiers to change | ||
| /// @param tiers The new tier list (sorted strictly ascending by maxAmount) | ||
| // solhint-disable-next-line comprehensive-interface | ||
| function queueTiersChange(Flow flow, ConfirmationTier[] calldata tiers) | ||
| external | ||
| onlyRole(DEFAULT_ADMIN_ROLE) | ||
| { | ||
| _validateTiers(tiers); | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| PendingTiers storage p = $.pendingTiers[flow]; | ||
| delete p.tiers; | ||
| for (uint256 i = 0; i < tiers.length; ++i) { | ||
| p.tiers.push(tiers[i]); | ||
| } | ||
| p.eta = block.timestamp + _getBounds().timelockDelay; | ||
| } | ||
|
|
||
| /// @notice Applies a queued confirmation-tier change once its time lock has elapsed. | ||
| /// @param flow The flow whose tiers to apply | ||
| // solhint-disable-next-line comprehensive-interface | ||
| function applyTiersChange(Flow flow) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| PendingTiers storage p = $.pendingTiers[flow]; | ||
| if (p.eta == 0) revert NoQueuedChange(flow); | ||
| if (block.timestamp < p.eta) revert TimelockNotElapsed(p.eta, block.timestamp); | ||
|
|
||
| // Re-validate at apply time as well. | ||
| _validateTiersStorage(p.tiers); | ||
|
|
||
| PegConfiguration storage config = flow == Flow.PegIn ? $.pegIn : $.pegOut; | ||
| ConfirmationTier[] memory oldTiers = config.confirmationTiers; | ||
| delete config.confirmationTiers; | ||
| for (uint256 i = 0; i < p.tiers.length; ++i) { | ||
| config.confirmationTiers.push(p.tiers[i]); | ||
| } | ||
| ConfirmationTier[] memory newTiers = config.confirmationTiers; | ||
|
|
||
| delete $.pendingTiers[flow]; | ||
| if (flow == Flow.PegIn) { | ||
| emit PegInConfirmationTiersChanged(oldTiers, newTiers); | ||
| } else { | ||
| emit PegOutConfirmationTiersChanged(oldTiers, newTiers); | ||
| } | ||
| } | ||
|
|
||
| // ============================ Aggregate getters ============================ | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getPegInConfiguration() external view override returns (PegConfiguration memory) { | ||
| return _getStorage().pegIn; | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getPegOutConfiguration() external view override returns (PegConfiguration memory) { | ||
| return _getStorage().pegOut; | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getRequiredPegInConfirmations(uint256 amount) external view override returns (uint256) { | ||
| return _requiredConfirmations(_getStorage().pegIn, amount); | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getRequiredPegOutConfirmations(uint256 amount) external view override returns (uint256) { | ||
| return _requiredConfirmations(_getStorage().pegOut, amount); | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function calculatePegInFee(uint256 amount) external view override returns (uint256) { | ||
| return _calculateFee(_getStorage().pegIn, amount); | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function calculatePegOutFee(uint256 amount) external view override returns (uint256) { | ||
| return _calculateFee(_getStorage().pegOut, amount); | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getPegInConfigurationBounds() | ||
| external | ||
| view | ||
| override | ||
| returns (PegConfiguration memory min, PegConfiguration memory max) | ||
| { | ||
| FlyoverConfigurationsBounds storage b = _getBounds(); | ||
| return (b.pegInMin, b.pegInMax); | ||
| } | ||
|
|
||
| /// @inheritdoc IFlyoverConfigurations | ||
| function getPegOutConfigurationBounds() | ||
| external | ||
| view | ||
| override | ||
| returns (PegConfiguration memory min, PegConfiguration memory max) | ||
| { | ||
| FlyoverConfigurationsBounds storage b = _getBounds(); | ||
| return (b.pegOutMin, b.pegOutMax); | ||
| } | ||
|
|
||
| /// @notice Returns the time-lock delay (seconds) applied to every queued change. | ||
| function getTimelockDelay() external view returns (uint256) { | ||
| return _getBounds().timelockDelay; | ||
| } | ||
|
|
||
| /// @notice Returns the queued scalar change for a slot (value, eta); eta == 0 means none queued. | ||
| function getPendingChange(Flow flow, Field field) external view returns (uint256 value, uint256 eta) { | ||
| PendingChange memory p = _getStorage().pendingScalar[flow][field]; | ||
| return (p.value, p.eta); | ||
| } | ||
|
|
||
| // ============================ Internal: fee / tiers ============================ | ||
|
|
||
| /// @dev fee = fixedFee + amount * percentageFee / 10_000, then rounded DOWN to a satoshi | ||
| /// boundary (mirrors Quotes.checkAgreedAmount), so on-chain fees agree with the bridge. | ||
| function _calculateFee(PegConfiguration storage config, uint256 amount) | ||
| private | ||
| view | ||
| returns (uint256) | ||
| { | ||
| uint256 fee = config.fixedFee + (amount * config.percentageFee) / FEE_PERCENTAGE_DENOMINATOR; | ||
| if (fee > SAT_TO_WEI_CONVERSION && (fee % SAT_TO_WEI_CONVERSION) != 0) { | ||
| fee -= (fee % SAT_TO_WEI_CONVERSION); | ||
| } | ||
| return fee; | ||
| } | ||
|
|
||
| /// @dev Returns the confirmations for the first tier whose maxAmount >= amount. If the amount | ||
| /// exceeds every tier's maxAmount, returns the highest (last) tier's confirmations. | ||
| function _requiredConfirmations(PegConfiguration storage config, uint256 amount) | ||
| private | ||
| view | ||
| returns (uint256) | ||
| { | ||
| ConfirmationTier[] storage tiers = config.confirmationTiers; | ||
| uint256 length = tiers.length; | ||
| for (uint256 i = 0; i < length; ++i) { | ||
| if (amount <= tiers[i].maxAmount) { | ||
| return tiers[i].confirmations; | ||
| } | ||
| } | ||
| return tiers[length - 1].confirmations; | ||
| } | ||
|
|
||
| function _validateTiers(ConfirmationTier[] calldata tiers) private pure { | ||
| if (tiers.length == 0) revert EmptyTiers(); | ||
| for (uint256 i = 1; i < tiers.length; ++i) { | ||
| if (tiers[i].maxAmount <= tiers[i - 1].maxAmount) revert TiersNotAscending(); | ||
| } | ||
| } | ||
|
|
||
| function _validateTiersStorage(ConfirmationTier[] storage tiers) private view { | ||
| if (tiers.length == 0) revert EmptyTiers(); | ||
| for (uint256 i = 1; i < tiers.length; ++i) { | ||
| if (tiers[i].maxAmount <= tiers[i - 1].maxAmount) revert TiersNotAscending(); | ||
| } | ||
| } | ||
|
|
||
| // ============================ Internal: bounds / validation ============================ | ||
|
|
||
| /// @dev Validates every scalar field of a full config plus the callTime/expireTime invariant | ||
| /// and the tier ordering. Used for the seed config at initialize time. | ||
| function _validateWholeConfig(Flow flow, PegConfiguration calldata config) private view { | ||
| _checkBound(flow, Field.FixedFee, config.fixedFee); | ||
| _checkBound(flow, Field.PercentageFee, config.percentageFee); | ||
| _checkBound(flow, Field.PenaltyFee, config.penaltyFee); | ||
| _checkBound(flow, Field.CallTime, config.callTime); | ||
| _checkBound(flow, Field.ExpireTime, config.expireTime); | ||
| _checkBound(flow, Field.ExpireBlocks, config.expireBlocks); | ||
| _checkBound(flow, Field.DeliveryGrace, config.deliveryGrace); | ||
| _checkBound(flow, Field.MinAmount, config.minAmount); | ||
| _checkBound(flow, Field.MaxAmount, config.maxAmount); | ||
| if (config.expireTime <= config.callTime) { | ||
| revert ExpireTimeNotAfterCallTime(config.callTime, config.expireTime); | ||
| } | ||
| _validateTiers(config.confirmationTiers); | ||
| } | ||
|
|
||
| /// @dev Enforces `expireTime > callTime` when either deadline field is being changed. | ||
| function _checkDeadlineInvariant(PegConfiguration storage config, Field field, uint256 newValue) | ||
| private | ||
| view | ||
| { | ||
| if (field == Field.CallTime) { | ||
| if (config.expireTime <= newValue) { | ||
| revert ExpireTimeNotAfterCallTime(newValue, config.expireTime); | ||
| } | ||
| } else if (field == Field.ExpireTime) { | ||
| if (newValue <= config.callTime) { | ||
| revert ExpireTimeNotAfterCallTime(config.callTime, newValue); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function _checkBound(Flow flow, Field field, uint256 value) private view { | ||
| FlyoverConfigurationsBounds storage b = _getBounds(); | ||
| PegConfiguration storage minC = flow == Flow.PegIn ? b.pegInMin : b.pegOutMin; | ||
| PegConfiguration storage maxC = flow == Flow.PegIn ? b.pegInMax : b.pegOutMax; | ||
| uint256 minV = _readField(minC, field); | ||
| uint256 maxV = _readField(maxC, field); | ||
| if (value < minV || value > maxV) { | ||
| revert OutOfBounds(flow, field, value, minV, maxV); | ||
| } | ||
| } | ||
|
|
||
| function _readField(PegConfiguration storage config, Field field) private view returns (uint256) { | ||
| if (field == Field.FixedFee) return config.fixedFee; | ||
| if (field == Field.PercentageFee) return config.percentageFee; | ||
| if (field == Field.PenaltyFee) return config.penaltyFee; | ||
| if (field == Field.CallTime) return config.callTime; | ||
| if (field == Field.ExpireTime) return config.expireTime; | ||
| if (field == Field.ExpireBlocks) return config.expireBlocks; | ||
| if (field == Field.DeliveryGrace) return config.deliveryGrace; | ||
| if (field == Field.MinAmount) return config.minAmount; | ||
| return config.maxAmount; // Field.MaxAmount | ||
| } | ||
|
|
||
| function _writeField(PegConfiguration storage config, Field field, uint256 value) private { | ||
| if (field == Field.FixedFee) config.fixedFee = value; | ||
| else if (field == Field.PercentageFee) config.percentageFee = value; | ||
| else if (field == Field.PenaltyFee) config.penaltyFee = value; | ||
| else if (field == Field.CallTime) config.callTime = value; | ||
| else if (field == Field.ExpireTime) config.expireTime = value; | ||
| else if (field == Field.ExpireBlocks) config.expireBlocks = value; | ||
| else if (field == Field.DeliveryGrace) config.deliveryGrace = value; | ||
| else if (field == Field.MinAmount) config.minAmount = value; | ||
| else config.maxAmount = value; // Field.MaxAmount | ||
| } | ||
|
|
||
| function _emitChange(Flow flow, Field field, uint256 oldValue, uint256 newValue) private { | ||
| if (flow == Flow.PegIn) { | ||
| if (field == Field.FixedFee) emit PegInFixedFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PercentageFee) emit PegInPercentageFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PenaltyFee) emit PegInPenaltyFeeChanged(oldValue, newValue); | ||
| else if (field == Field.CallTime) emit PegInCallTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireTime) emit PegInExpireTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireBlocks) emit PegInExpireBlocksChanged(oldValue, newValue); | ||
| else if (field == Field.DeliveryGrace) emit PegInDeliveryGraceChanged(oldValue, newValue); | ||
| else if (field == Field.MinAmount) emit PegInMinAmountChanged(oldValue, newValue); | ||
| else emit PegInMaxAmountChanged(oldValue, newValue); | ||
| } else { | ||
| if (field == Field.FixedFee) emit PegOutFixedFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PercentageFee) emit PegOutPercentageFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PenaltyFee) emit PegOutPenaltyFeeChanged(oldValue, newValue); | ||
| else if (field == Field.CallTime) emit PegOutCallTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireTime) emit PegOutExpireTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireBlocks) emit PegOutExpireBlocksChanged(oldValue, newValue); | ||
| else if (field == Field.DeliveryGrace) emit PegOutDeliveryGraceChanged(oldValue, newValue); | ||
| else if (field == Field.MinAmount) emit PegOutMinAmountChanged(oldValue, newValue); | ||
| else emit PegOutMaxAmountChanged(oldValue, newValue); | ||
| } | ||
| } | ||
|
|
||
| // ============================ Internal: storage copy helpers ============================ | ||
|
|
||
| function _copyConfig(PegConfiguration storage dst, PegConfiguration calldata src) private { | ||
| dst.fixedFee = src.fixedFee; | ||
| dst.percentageFee = src.percentageFee; | ||
| dst.penaltyFee = src.penaltyFee; | ||
| dst.callTime = src.callTime; | ||
| dst.expireTime = src.expireTime; | ||
| dst.expireBlocks = src.expireBlocks; | ||
| dst.deliveryGrace = src.deliveryGrace; | ||
| dst.minAmount = src.minAmount; | ||
| dst.maxAmount = src.maxAmount; | ||
| delete dst.confirmationTiers; | ||
| for (uint256 i = 0; i < src.confirmationTiers.length; ++i) { | ||
| dst.confirmationTiers.push(src.confirmationTiers[i]); | ||
| } | ||
| } | ||
|
|
||
| /// @dev Bounds only constrain the scalar fields; tier/deadline invariants do not apply to them. | ||
| function _copyBounds(PegConfiguration storage dst, PegConfiguration calldata src) private { | ||
| dst.fixedFee = src.fixedFee; | ||
| dst.percentageFee = src.percentageFee; | ||
| dst.penaltyFee = src.penaltyFee; | ||
| dst.callTime = src.callTime; | ||
| dst.expireTime = src.expireTime; | ||
| dst.expireBlocks = src.expireBlocks; | ||
| dst.deliveryGrace = src.deliveryGrace; | ||
| dst.minAmount = src.minAmount; | ||
| dst.maxAmount = src.maxAmount; | ||
| } | ||
|
|
||
| function _getStorage() private pure returns (FlyoverConfigurationsStorage storage $) { | ||
| assembly { | ||
| $.slot := _FLYOVER_CONFIGURATIONS_STORAGE | ||
| } | ||
| } | ||
|
|
||
| function _getBounds() private pure returns (FlyoverConfigurationsBounds storage $) { | ||
| assembly { | ||
| $.slot := _FLYOVER_CONFIGURATIONS_BOUNDS | ||
| } | ||
| } | ||
| } |
Comment on lines
+422
to
+444
| function _emitChange(Flow flow, Field field, uint256 oldValue, uint256 newValue) private { | ||
| if (flow == Flow.PegIn) { | ||
| if (field == Field.FixedFee) emit PegInFixedFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PercentageFee) emit PegInPercentageFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PenaltyFee) emit PegInPenaltyFeeChanged(oldValue, newValue); | ||
| else if (field == Field.CallTime) emit PegInCallTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireTime) emit PegInExpireTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireBlocks) emit PegInExpireBlocksChanged(oldValue, newValue); | ||
| else if (field == Field.DeliveryGrace) emit PegInDeliveryGraceChanged(oldValue, newValue); | ||
| else if (field == Field.MinAmount) emit PegInMinAmountChanged(oldValue, newValue); | ||
| else emit PegInMaxAmountChanged(oldValue, newValue); | ||
| } else { | ||
| if (field == Field.FixedFee) emit PegOutFixedFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PercentageFee) emit PegOutPercentageFeeChanged(oldValue, newValue); | ||
| else if (field == Field.PenaltyFee) emit PegOutPenaltyFeeChanged(oldValue, newValue); | ||
| else if (field == Field.CallTime) emit PegOutCallTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireTime) emit PegOutExpireTimeChanged(oldValue, newValue); | ||
| else if (field == Field.ExpireBlocks) emit PegOutExpireBlocksChanged(oldValue, newValue); | ||
| else if (field == Field.DeliveryGrace) emit PegOutDeliveryGraceChanged(oldValue, newValue); | ||
| else if (field == Field.MinAmount) emit PegOutMinAmountChanged(oldValue, newValue); | ||
| else emit PegOutMaxAmountChanged(oldValue, newValue); | ||
| } | ||
| } |
Comment on lines
+477
to
+481
| function _getStorage() private pure returns (FlyoverConfigurationsStorage storage $) { | ||
| assembly { | ||
| $.slot := _FLYOVER_CONFIGURATIONS_STORAGE | ||
| } | ||
| } |
Comment on lines
+174
to
+189
| function applyChange(Flow flow, Field field) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| PendingChange memory pending = $.pendingScalar[flow][field]; | ||
| if (pending.eta == 0) revert NoQueuedChange(flow); | ||
| if (block.timestamp < pending.eta) revert TimelockNotElapsed(pending.eta, block.timestamp); | ||
|
|
||
| _checkBound(flow, field, pending.value); | ||
|
|
||
| PegConfiguration storage config = flow == Flow.PegIn ? $.pegIn : $.pegOut; | ||
| uint256 oldValue = _readField(config, field); | ||
| _checkDeadlineInvariant(config, field, pending.value); | ||
| _writeField(config, field, pending.value); | ||
|
|
||
| delete $.pendingScalar[flow][field]; | ||
| _emitChange(flow, field, oldValue, pending.value); | ||
| } |
Comment on lines
+214
to
+237
| function applyTiersChange(Flow flow) external onlyRole(DEFAULT_ADMIN_ROLE) { | ||
| FlyoverConfigurationsStorage storage $ = _getStorage(); | ||
| PendingTiers storage p = $.pendingTiers[flow]; | ||
| if (p.eta == 0) revert NoQueuedChange(flow); | ||
| if (block.timestamp < p.eta) revert TimelockNotElapsed(p.eta, block.timestamp); | ||
|
|
||
| // Re-validate at apply time as well. | ||
| _validateTiersStorage(p.tiers); | ||
|
|
||
| PegConfiguration storage config = flow == Flow.PegIn ? $.pegIn : $.pegOut; | ||
| ConfirmationTier[] memory oldTiers = config.confirmationTiers; | ||
| delete config.confirmationTiers; | ||
| for (uint256 i = 0; i < p.tiers.length; ++i) { | ||
| config.confirmationTiers.push(p.tiers[i]); | ||
| } | ||
| ConfirmationTier[] memory newTiers = config.confirmationTiers; | ||
|
|
||
| delete $.pendingTiers[flow]; | ||
| if (flow == Flow.PegIn) { | ||
| emit PegInConfirmationTiersChanged(oldTiers, newTiers); | ||
| } else { | ||
| emit PegOutConfirmationTiersChanged(oldTiers, newTiers); | ||
| } | ||
| } |
Comment on lines
+245
to
+255
| function _deriveAddress(address addr) private view returns (bytes memory) { | ||
| PegInAddressRegistryStorage storage $ = _getStorage(); | ||
| address pegInContract = $.pegInContract; | ||
| if (pegInContract == address(0)) revert PegInContractNotSet(); | ||
| return PegInDerivation.depositAddressPayload( | ||
| addr, | ||
| pegInContract, | ||
| $.bridge.getActivePowpegRedeemScript(), | ||
| $.mainnet | ||
| ); | ||
| } |
Comment on lines
+457
to
+475
| function resolvePegIn( | ||
| address rskAddr, | ||
| bytes32 btcTxHash, | ||
| bytes calldata btcRawTransaction, | ||
| bytes calldata partialMerkleTree, | ||
| uint256 height, | ||
| address payable registrant | ||
| ) external nonReentrant whenNotHardPaused returns (int256 bridgeResult) { | ||
| bytes32 id = _pegInId(rskAddr, btcTxHash); | ||
| if (_claims[id].claimer == address(0)) revert PegInNotClaimed(id); | ||
| if (_claims[id].resolved) revert PegInAlreadyProcessed(id); | ||
|
|
||
| // Settle with the Bridge: release the deposited RBTC to this contract. | ||
| bridgeResult = _settleWithBridge(rskAddr, btcRawTransaction, partialMerkleTree, height); | ||
| if (bridgeResult < _MIN_VALID_BRIDGE_RESULT) revert BridgeSettlementFailed(bridgeResult); | ||
|
|
||
| _claims[id].resolved = true; | ||
| _reimburseClaim(id, rskAddr, registrant); | ||
| } |
Comment on lines
+680
to
+703
| function _deliver( | ||
| address rskAddr, | ||
| uint256 netAmount, | ||
| bytes calldata opReturn | ||
| ) private returns (bool callSuccess) { | ||
| (bool isCall, address destination, uint256 maxGasFee, bytes memory callData) = _parseOpReturn(opReturn); | ||
|
|
||
| if (!isCall) { | ||
| // Plain peg-in: send the net amount to the RSK address. | ||
| (bool ok,) = payable(rskAddr).call{value: netAmount}(""); | ||
| if (!ok) revert Flyover.PaymentFailed(rskAddr, netAmount, ""); | ||
| return true; | ||
| } | ||
|
|
||
| // SC-call peg-in: call the destination with callData and the net amount, capped at maxGasFee gas. | ||
| uint256 gasCap = maxGasFee == 0 ? gasleft() : maxGasFee; | ||
| (callSuccess,) = destination.call{value: netAmount, gas: gasCap}(callData); | ||
|
|
||
| if (!callSuccess) { | ||
| // Reverting destination: refund the RSK address and mark a failed call; do NOT revert the peg-in. | ||
| (bool refunded,) = payable(rskAddr).call{value: netAmount}(""); | ||
| if (!refunded) revert Flyover.PaymentFailed(rskAddr, netAmount, ""); | ||
| } | ||
| } |
Implements the E11.1/E11.2 refund rail: resolvePegIn now handles a peg-in that NO LP fronted. It settles with the bridge, forwards amount - fee to the user's rskAddr on the RBTC rail, and — if the peg-in is serviceable and past its claim deadline (anchored to the registration block) — global-slashes the network on the same resolve. Supersedes the standalone E4.4 slashUnclaimedPegIn, which slashed without settling and thus stranded the user's deposit; removing it also kept PegInContract under the EIP-170 limit (24,271/24,576). Address-safe: no change to the deposit-address derivation. The BTC-refund-field decision (address-rotating) stays deferred to E11.5. - src/PegInContract.sol: _resolveUnclaimed + resolvePegIn no-claimer branch; new PegInRefundedToUser event; drop PegInNotClaimed/ClaimDeadlineNotReached. - test/pegin/UnclaimedSlash.t.sol: rewritten to exercise the resolve-based rail. - test/pegin/ResolvePegIn.t.sol: unclaimed-resolve now forwards+slashes. - script/regtest-pegin/08-test-refund-path.sh: live proof (skips requestPegIn, advances past the deadline, asserts user forwarded + LP slashed + processed). - docs/dos-removal: vendored E11 epic + refreshed EPICs index. Proven on live regtest (rskj 9.0.2, real powpeg bridge): user 0 -> 0.9989 RBTC, LP collateral global-slashed, peg-in processed; re-runnable. 698 Foundry tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines
+555
to
+597
| function _resolveUnclaimed( | ||
| bytes32 id, | ||
| address rskAddr, | ||
| bytes calldata btcRawTransaction, | ||
| bytes calldata partialMerkleTree, | ||
| uint256 height | ||
| ) private returns (int256 bridgeResult) { | ||
| if (address(_registry) == address(0) || address(_configurations) == address(0)) { | ||
| revert DependencyNotSet(); | ||
| } | ||
| if (!_registry.isRegistered(rskAddr)) revert AddressNotRegistered(rskAddr); | ||
|
|
||
| // Settle with the Bridge: release the deposited RBTC to this contract. The released amount is the | ||
| // peg-in amount (there is no claim record to read it from). | ||
| bridgeResult = _settleWithBridge(rskAddr, btcRawTransaction, partialMerkleTree, height); | ||
| if (bridgeResult < _MIN_VALID_BRIDGE_RESULT) revert BridgeSettlementFailed(bridgeResult); | ||
|
|
||
| uint256 amount = uint256(bridgeResult); | ||
| uint256 netToUser = amount - _configurations.calculatePegInFee(amount); | ||
|
|
||
| // Effects before interactions: mark processed so this peg-in cannot be resolved/slashed twice. | ||
| _claims[id] = PegInClaim({ | ||
| claimer: address(this), | ||
| amount: amount, | ||
| fee: amount - netToUser, | ||
| requestBlock: block.number, | ||
| resolved: true | ||
| }); | ||
|
|
||
| // Global slash if the peg-in was serviceable (>= minimum) and left unserved past its deadline, | ||
| // anchored to the registration block. Below-minimum amounts are refunded but not penalizable. | ||
| uint256 minAmount = _configurations.getPegInConfiguration().minAmount; | ||
| uint256 deadlineBlock = _registry.getRegistrationBlock(rskAddr) + _claimDeadlineBlocks; | ||
| if (amount >= minAmount && block.number > deadlineBlock) { | ||
| _collateralManagement.globalSlash(_configurations.getPegInConfiguration().penaltyFee); | ||
| emit UnclaimedPegInSlashed(rskAddr, amount); | ||
| } | ||
|
|
||
| // Forward the net amount to the user (plain peg-in; SC-call routing is E11.3). | ||
| (bool ok,) = payable(rskAddr).call{value: netToUser}(""); | ||
| if (!ok) revert Flyover.PaymentFailed(rskAddr, netToUser, ""); | ||
| emit PegInRefundedToUser(id, rskAddr, netToUser); | ||
| } |
Comment on lines
+555
to
+597
| function _resolveUnclaimed( | ||
| bytes32 id, | ||
| address rskAddr, | ||
| bytes calldata btcRawTransaction, | ||
| bytes calldata partialMerkleTree, | ||
| uint256 height | ||
| ) private returns (int256 bridgeResult) { | ||
| if (address(_registry) == address(0) || address(_configurations) == address(0)) { | ||
| revert DependencyNotSet(); | ||
| } | ||
| if (!_registry.isRegistered(rskAddr)) revert AddressNotRegistered(rskAddr); | ||
|
|
||
| // Settle with the Bridge: release the deposited RBTC to this contract. The released amount is the | ||
| // peg-in amount (there is no claim record to read it from). | ||
| bridgeResult = _settleWithBridge(rskAddr, btcRawTransaction, partialMerkleTree, height); | ||
| if (bridgeResult < _MIN_VALID_BRIDGE_RESULT) revert BridgeSettlementFailed(bridgeResult); | ||
|
|
||
| uint256 amount = uint256(bridgeResult); | ||
| uint256 netToUser = amount - _configurations.calculatePegInFee(amount); | ||
|
|
||
| // Effects before interactions: mark processed so this peg-in cannot be resolved/slashed twice. | ||
| _claims[id] = PegInClaim({ | ||
| claimer: address(this), | ||
| amount: amount, | ||
| fee: amount - netToUser, | ||
| requestBlock: block.number, | ||
| resolved: true | ||
| }); | ||
|
|
||
| // Global slash if the peg-in was serviceable (>= minimum) and left unserved past its deadline, | ||
| // anchored to the registration block. Below-minimum amounts are refunded but not penalizable. | ||
| uint256 minAmount = _configurations.getPegInConfiguration().minAmount; | ||
| uint256 deadlineBlock = _registry.getRegistrationBlock(rskAddr) + _claimDeadlineBlocks; | ||
| if (amount >= minAmount && block.number > deadlineBlock) { | ||
| _collateralManagement.globalSlash(_configurations.getPegInConfiguration().penaltyFee); | ||
| emit UnclaimedPegInSlashed(rskAddr, amount); | ||
| } | ||
|
|
||
| // Forward the net amount to the user (plain peg-in; SC-call routing is E11.3). | ||
| (bool ok,) = payable(rskAddr).call{value: netToUser}(""); | ||
| if (!ok) revert Flyover.PaymentFailed(rskAddr, netToUser, ""); | ||
| emit PegInRefundedToUser(id, rskAddr, netToUser); | ||
| } |
Comment on lines
+555
to
+597
| function _resolveUnclaimed( | ||
| bytes32 id, | ||
| address rskAddr, | ||
| bytes calldata btcRawTransaction, | ||
| bytes calldata partialMerkleTree, | ||
| uint256 height | ||
| ) private returns (int256 bridgeResult) { | ||
| if (address(_registry) == address(0) || address(_configurations) == address(0)) { | ||
| revert DependencyNotSet(); | ||
| } | ||
| if (!_registry.isRegistered(rskAddr)) revert AddressNotRegistered(rskAddr); | ||
|
|
||
| // Settle with the Bridge: release the deposited RBTC to this contract. The released amount is the | ||
| // peg-in amount (there is no claim record to read it from). | ||
| bridgeResult = _settleWithBridge(rskAddr, btcRawTransaction, partialMerkleTree, height); | ||
| if (bridgeResult < _MIN_VALID_BRIDGE_RESULT) revert BridgeSettlementFailed(bridgeResult); | ||
|
|
||
| uint256 amount = uint256(bridgeResult); | ||
| uint256 netToUser = amount - _configurations.calculatePegInFee(amount); | ||
|
|
||
| // Effects before interactions: mark processed so this peg-in cannot be resolved/slashed twice. | ||
| _claims[id] = PegInClaim({ | ||
| claimer: address(this), | ||
| amount: amount, | ||
| fee: amount - netToUser, | ||
| requestBlock: block.number, | ||
| resolved: true | ||
| }); | ||
|
|
||
| // Global slash if the peg-in was serviceable (>= minimum) and left unserved past its deadline, | ||
| // anchored to the registration block. Below-minimum amounts are refunded but not penalizable. | ||
| uint256 minAmount = _configurations.getPegInConfiguration().minAmount; | ||
| uint256 deadlineBlock = _registry.getRegistrationBlock(rskAddr) + _claimDeadlineBlocks; | ||
| if (amount >= minAmount && block.number > deadlineBlock) { | ||
| _collateralManagement.globalSlash(_configurations.getPegInConfiguration().penaltyFee); | ||
| emit UnclaimedPegInSlashed(rskAddr, amount); | ||
| } | ||
|
|
||
| // Forward the net amount to the user (plain peg-in; SC-call routing is E11.3). | ||
| (bool ok,) = payable(rskAddr).call{value: netToUser}(""); | ||
| if (!ok) revert Flyover.PaymentFailed(rskAddr, netToUser, ""); | ||
| emit PegInRefundedToUser(id, rskAddr, netToUser); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Liquidity-DoS removal — commit-first peg-in (contracts)
Replaces off-chain quote negotiation with a user-commits-first peg-in: the user commits BTC on-chain first to a deterministic address derived from their RSK address; LPs detect it and compete to serve it; no liquidity is reserved before the user commits. Closes the zero-cost accept-spam DoS structurally and fixes the institutional whitelist/expiry blockers.
Contracts:
FlyoverConfigurations(E1),PegInAddressRegistry(E2), global slash + grace window (E3),requestPegIn/resolvePegInclaim flow (E4), and bridge-compatible derivation (EB —resolvePegInsettles with no bridge changes, via sharedPegInDerivation.sol).Proven end-to-end on regtest (rskj 9.0.2, real bridge precompile): derive → BTC deposit → SPV-gated registration →
requestPegIn(user creditedamount − fee) →resolvePegIn(LP reimbursed). 698 Foundry tests green; contracts under EIP-170.E11 — peg-in refund rail (no-claimer settle + global slash)
Adds the non-happy path: when no LP fronts a valid peg-in,
resolvePegInnow settles it anyway, forwardsamount − feeto the user'srskAddron the RBTC rail, and — if the peg-in is serviceable and past its claim deadline (anchored to the registration block) — global-slashes the network on the same resolve. The user is always made whole from their own deposit; LP fronting only ever buys speed. This supersedes the standalone E4.4slashUnclaimedPegIn(which slashed without settling and stranded the deposit); removing it keptPegInContractunder EIP-170 (24,271/24,576).Address-safe: no change to the deposit-address derivation. The BTC-refund-field decision (address-rotating) is deferred to E11.5. Proven on live regtest via
script/regtest-pegin/08-test-refund-path.sh(skipsrequestPegIn, advances past the deadline so the slash fires): user0 → 0.9989 RBTC, LP collateral global-slashed, peg-in processed; re-runnable. E11.3 (SC-call-revert refund) and E11.4 (watchtower / permissionless resolve) remain planned.PoC scope. Design docs + runnable runbook under
docs/dos-removal/andscript/regtest-pegin/. Production TODO: set theREFUND/LP_PLACEHOLDERBTC addresses. Draft — not for merge.🤖 Generated with Claude Code