Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions solidity/contracts/bridge/Reservation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,18 @@ library Reservation {
// sum would exceed the uint32 ceiling - starting somewhat before
// February 7th 2106, proportional to the delay applied on top of
// expiresAt's own margin.
// As of this milestone, this field's only on-chain reader —
// re-anchor's `< dissolutionEligibleAt` gate in
// `requestReservationReanchor` — has been removed. The field is
// written by `settleAcceptance` (ReservationProofs.sol:579) but
// read by nothing in m1. It must continue to be written anyway:
// The Closing-wallet branch of `notifyReservationStranded`
// is the single on-chain reader of this field: it strands a
// reservation only once its custodying wallet is Closing and
// `block.timestamp >= dissolutionEligibleAt`. Re-anchor does
// not read it. `settleAcceptance` writes it on every
// acceptance. It must continue to be written:
// storage-completeness for a milestone-2 field means written, not
// merely declared, and this field is a commitment held in storage
// for m2's dissolution feature to honour. m2 must independently
// decide whether to restore an eligibility gate on re-anchor when
// dissolution ships; this PR's removal defers that design decision
// without answering it.
// dissolution ships; that design decision is deferred to m2
// without being answered here.
uint32 dissolutionEligibleAt;
// Cumulative satoshi lost to Bitcoin miner fees across all
// re-anchor hops of this reservation. Will be written on every
Expand Down
46 changes: 7 additions & 39 deletions solidity/contracts/bridge/WalletProposalValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -957,21 +957,19 @@ contract WalletProposalValidator {
/// has not timed out,
/// - The deposit must be revealed, old enough, not swept, and
/// routed to the reservation vault,
/// - The proposed fee must be positive, within the authorized
/// action's snapshotted max fee, and must leave an anchor amount
/// above the reservation minimum,
/// - The proposed fee must be positive and within the authorized
/// action's snapshotted max fee, and the resulting anchor amount
/// must stay above the action's snapshotted reservation minimum,
/// - The deposit extra info must be valid and preserve the refund
/// safety margin,
/// - The deposit must be controlled by the proposal wallet.
function validateReservationAnchorProposal(
ReservationAnchorProposal calldata proposal,
DepositExtraInfo calldata depositExtraInfo
) external view returns (bool) {
(
address reservationVault,
uint64 reservationMinAmount,

) = reservationVaultAndFees();
(address reservationVault, , , , , , , , , ) = IReservationBridge(
address(bridge)
).reservationParameters();

require(reservationVault != address(0), "Reservations are disabled");

Expand Down Expand Up @@ -1051,8 +1049,7 @@ contract WalletProposalValidator {
// (yielding the clear message below) for every case that would
// have underflowed.
require(
depositRequest.amount >=
reservationMinAmount + proposal.anchorTxFee,
depositRequest.amount >= action.minAmount + proposal.anchorTxFee,
"Anchor amount below the reservation minimum"
);

Expand Down Expand Up @@ -1081,35 +1078,6 @@ contract WalletProposalValidator {
return true;
}

/// @notice Fetches the reservation vault address and fee parameters
/// shared by the reservation anchor and re-anchor proposal
/// validators.
/// @return reservationVault The reservation vault address.
/// @return reservationMinAmount The minimum reservation amount.
/// @return reservationTxMaxFee The maximum reservation transaction fee.
function reservationVaultAndFees()
internal
view
returns (
address reservationVault,
uint64 reservationMinAmount,
uint64 reservationTxMaxFee
)
{
(
reservationVault,
reservationMinAmount,
reservationTxMaxFee,
,
,
,
,
,
,

) = IReservationBridge(address(bridge)).reservationParameters();
}

/// @notice View function encapsulating the main rules of a valid
/// reservation re-anchor proposal.
/// @param proposal The re-anchor proposal to validate.
Expand Down
11 changes: 8 additions & 3 deletions solidity/contracts/vault/ReservationVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,14 @@ contract ReservationVault is IVault, IReservationFeeFinancer, Ownable {
/// @dev Requirements:
/// - The caller must be the vault owner (governance).
///
/// If the balance (after satisfying outstanding debt) does not
/// exceed the reserve target, the call returns without reverting,
/// so a debt repayment performed above is still persisted.
/// Outstanding in-kind fee debt is repaid from the vault's
/// full current TBTC balance, including the TBTC that
/// `feeReserveTarget` would otherwise keep; `feeReserveTarget`
/// bounds only the sweepable surplus and is not a floor
/// protecting debt repayment. If the balance (after
/// satisfying outstanding debt) does not exceed the reserve
/// target, the call returns without reverting, so a debt
/// repayment performed above is still persisted.
function sweepFees(address recipient) external onlyOwner {
require(recipient != address(0), "Recipient must not be zero");

Expand Down
44 changes: 43 additions & 1 deletion solidity/deploy/97_set_reservation_parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,29 @@ import type { DeployFunction } from "hardhat-deploy/types"
*
* 3. `setVaultStatus(vault, true)` via `BridgeGovernance` — marks the
* vault as trusted. Until this runs, deposits cannot be revealed
* with the vault.
* with the vault. Activation-ordering gate: on a real network this
* must execute at or after this network's keep-core activation
* block; see solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md,
* "Client Activation Ordering Gate".
*
* The `reservationVault` is set as the first argument of
* `beginReservationParametersUpdate` — there is no separate
* `setReservationVault` setter.
*
* Fee-reserve notice: this script does not set the vault's fee reserve
* target (it deploys at 0); governance must run
* `ReservationVault.updateFeeReserveTarget(target)` - or explicitly
* accept a zero reserve - before on-chain activation; see
* solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, "Fee-Reserve Target
* Governance Step".
*
* Trust-revocation notice: if governance later revokes trust with
* `setVaultStatus(vault, false)`, every acceptance settled while the
* vault is untrusted (on time or late) settles by direct credit to the
* depositor with no initiation fee; see
* solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, "Fee Fallback When
* Vault Trust Is Revoked".
*
* Test-network shortcut: on local development networks (hardhat,
* localhost, development, system_tests) where the timelock is bypassed,
* this script may begin and finalize in the same deploy run. On live
Expand Down Expand Up @@ -156,6 +173,10 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
// revealed as ordinary (non-reserved) deposits. Step 2's finalize only
// runs synchronously here on local networks; on live non-mainnet
// networks it is deferred, so this step must be deferred too.
// Activation-ordering gate: on a real network this must execute at or
// after this network's keep-core activation block; see
// solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, "Client Activation
// Ordering Gate".
if (isLocalNetwork) {
deployments.log("[3/3] Activating vault via setVaultStatus")
await execute(
Expand All @@ -172,6 +193,27 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
"Run separately after finalizeReservationParametersUpdate has been executed " +
"and confirmed on-chain (do not activate while reservationVault is still zero)"
)
deployments.log(
"Activation-ordering gate: setVaultStatus(vault, true) must execute " +
"at or after this network's keep-core activation block; see " +
"solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Client Activation Ordering Gate'"
)
deployments.log(
"Fee-reserve notice: this script does not set the vault's fee " +
"reserve target (it deploys at 0); governance must run " +
"ReservationVault.updateFeeReserveTarget(target), or explicitly " +
"accept a zero reserve, before activation; see " +
"solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Fee-Reserve Target Governance Step'"
)
deployments.log(
"Trust-revocation notice: after setVaultStatus(vault, false), " +
"every acceptance settled while the vault is untrusted (on time " +
"or late) settles by direct credit to the depositor with no " +
"initiation fee; see solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Fee Fallback When Vault Trust Is Revoked'"
)
}
}

Expand Down
31 changes: 30 additions & 1 deletion solidity/deploy/98_generate_reservation_mainnet_calldata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ export function buildReservationActionDefinitions(params: {
"been confirmed on-chain -- otherwise the vault is marked trusted " +
"while reservationVault is still the zero address, letting " +
"deposits routed to the vault be revealed as ordinary " +
"(non-reserved) deposits.",
"(non-reserved) deposits. Activation-ordering gate: also execute " +
"at or after this network's keep-core activation block; see " +
"solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Client Activation Ordering Gate'.",
},
},
]
Expand Down Expand Up @@ -231,6 +234,32 @@ function logCalldataSummary(actions: CalldataAction[]): void {
"172800s (48h), read from governanceDelays(0); setReservationRouter " +
"and setVaultStatus carry no staging delay (one-off actions)."
)
// Fee-reserve notice: none of the six actions above configures the
// vault's fee reserve target; governance must run a separate
// ReservationVault.updateFeeReserveTarget(target) step (or explicitly
// accept a zero reserve) before activation; see
// solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, "Fee-Reserve Target
// Governance Step".
console.log(
"Fee-reserve notice: no action in this set configures the vault's " +
"fee reserve target; governance must run " +
"ReservationVault.updateFeeReserveTarget(target) as a separate " +
"action before activation, or explicitly accept a zero reserve; " +
"see solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Fee-Reserve Target Governance Step'."
)
// Trust-revocation notice: after setVaultStatus(vault, false), every
// acceptance settled while the vault is untrusted (on time or late)
// settles by direct credit to the depositor with no initiation fee;
// see solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, "Fee Fallback When
// Vault Trust Is Revoked".
console.log(
"Trust-revocation notice: after setVaultStatus(vault, false), every " +
"acceptance settled while the vault is untrusted (on time or late) " +
"settles by direct credit to the depositor with no initiation fee; " +
"see solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md, " +
"'Fee Fallback When Vault Trust Is Revoked'."
)
console.log("=".repeat(80))
}

Expand Down
36 changes: 36 additions & 0 deletions solidity/docs/RESERVATION_CAPS_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ The existing regression test `Bridge.ReservationCaps.test.ts` in the `describe("
>
> Setting a non-zero `reservationVault` in `updateReservationParameters` and accepting the first reservation permanently locks in that vault contract for the entire lifetime of live reservations. Explicit governance and deployer sign-off acknowledging this irreversibility is **required** prior to the activation ceremony.

## Client Activation Ordering Gate

On-chain activation (`setVaultStatus(vault, true)`) and client activation (keep-core's reservation task scheduling) are two independent axes that MUST be coupled in this order:

1. **Ship the keep-core release first.** Release and deploy a keep-core binary whose `reservationsActivationBlocks` table contains the chosen entry for the target network (see `keep-core pkg/tbtc/coordination.go`: `ReservationsActivationBlock`). All maintainers, orchestrators, and tBTCpg coordinators on that network must run that release before the on-chain activation block is reached.
2. **Choose the activation block at or after the release is live on every node.** The value written into `reservationsActivationBlocks` for the network is the block height at which clients start proposing acceptance and re-anchor actions. The `setVaultStatus(vault, true)` transaction must execute at or after that block.

Consequence of the wrong order (activating on-chain before the released clients are live): the Bridge immediately accepts reserved reveals and acceptance requests, but no keep-core client coordinates acceptance or re-anchor until the network's activation block is reached. keep-core starts coordinating a network only once the chain's block height reaches the activation block configured for that network in `reservationsActivationBlocks` (see `keep-core pkg/tbtc/coordination.go`: `ReservationsActivationBlock`), so the gap has two distinct shapes:

- **(a) Network with a configured activation block:** the wrong order opens a temporary gap - reserved deposits can be revealed and accepted on-chain while no client produces the anchor proof, and the gap lasts only until the configured block, after which coordination starts and the positions settle normally.
- **(b) Network with no entry in `reservationsActivationBlocks`:** `ReservationsActivationBlock` falls through to `math.MaxUint64` and the client never activates reservation coordination for that network. Reserved deposits can still be revealed and acceptance requested on-chain, but no client coordinates anchor signing, so without a separately signed and confirmed anchor transaction an SPV maintainer has no acceptance proof to submit. The normal fallback is that pending acceptances time out and depositors take the Bitcoin refund after the deposit locktime, after which the stale pending-deposit record can be cleared. If the custodying wallet does sign and broadcast an anchor outside client coordination, an SPV maintainer can still prove it while the acceptance is pending or, after timeout, within the late-settlement window (`timeoutAt + termSeconds`).

This gate is runbook discipline; nothing on-chain enforces it.

## Fee Fallback When Vault Trust Is Revoked

Governance can revoke the reveal-time vault's trust at any point, including while acceptance actions are still proof-settleable (pending or after their `timeoutAt`, within the late-settlement window). Once governance executes `setVaultStatus(vault, false)`, every acceptance that settles against that vault while it is untrusted - on time or late - takes a direct-credit fallback: instead of routing the gross anchored amount through `ReservationVault.receiveBalanceIncrease` (which would mint TBTC and retain the initiation fee as in-kind fee reserve), the Bridge credits the depositor directly through `Bank.increaseBalances`. The reservation position is still registered and the anchor is still indexed, but **no initiation fee is charged** and no fee reserve accumulates in the vault for that settlement. The `settleAcceptance` branch checks `isVaultTrusted[vault]` without checking lateness, so the fallback does not discriminate between on-time and late proofs.

Operational consequences:

- Fee revenue accounting must not assume every settled acceptance retains its initiation fee in the vault. Any settlement against a since-untrusted vault - on time or late - produces zero vault-side fee income for that position.
- This is the intended M1 behavior: a confirmed Bitcoin anchor must settle even if the vault lost trust, so the fallback credits the depositor directly rather than reverting the already-confirmed BTC spend. Re-trusting the vault does not retroactively charge the skipped fee.
- Before revoking trust, governance should verify that no pending or timed-out acceptance actions remain on that vault, or accept the fee loss on the settlements that race the revocation.

## Fee-Reserve Target Governance Step

`ReservationVault` deploys with `feeReserveTarget == 0`. Nothing in the activation flow (scripts 95/96/97 or the mainnet calldata script 98) calls `updateFeeReserveTarget`: script 97 performs only the caps update, the parameters update, and `setVaultStatus(vault, true)`. Re-anchor settlement already finances miner fees in kind in milestone 1, so the reserve-target decision cannot wait for a later milestone: a zero reserve target means `sweepFees` may sweep the vault's entire TBTC balance to the recipient, including the TBTC that in-kind fee financing needs: if a re-anchor hop or late fee event consumes more than the vault holds, the shortfall is recorded as `inKindFeeDebtSat` and the system runs over-supplied by that amount until `repayInKindFeeDebt` or a later `sweepFees` burns it back down. This does not block any milestone-1 settlement, but the fee debt must be consciously owned.

Explicit governance step to add to the activation runbook, to be completed before on-chain activation (`setVaultStatus(vault, true)`), i.e. before the first M1 re-anchor settlement can land in kind:

1. Choose the target: a TBTC amount (18 decimals) large enough to cover the expected in-kind miner-fee pipeline, or deliberately accept a zero reserve.
2. Execute `ReservationVault.updateFeeReserveTarget(target)` via the Council Safe (`onlyOwner`-gated; the vault owner is governance).
3. Verify: call the public getter `ReservationVault.feeReserveTarget()` and confirm it returns the chosen value; the state change is also observable as a `FeeReserveTargetUpdated` event.

Record the decision in the governance log. If a zero reserve is accepted, note that in-kind fee debt is expected to be repaid opportunistically and is a known, publicly visible (`inKindFeeDebtSat`) accounting item.

---

## Occupancy Lifecycle and Capacity Management (maxActiveReservations)
Expand Down
Loading
Loading