Skip to content

Commit e4c6b5f

Browse files
authored
fix: ensure mint amount is greater than the dust limit (#1178)
* Handle the case where the deposit amount is below the dust limit in stacks validation * Handle low mint amounts during deposit request filtering * Make sure that we validate mint amount and dust amounts in bitcoin validation
1 parent 891677e commit e4c6b5f

File tree

7 files changed

+303
-51
lines changed

7 files changed

+303
-51
lines changed

signer/src/bitcoin/utxo.rs

+68-4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ use crate::storage::model::TxOutput;
5151
use crate::storage::model::TxOutputType;
5252
use crate::storage::model::TxPrevout;
5353
use crate::storage::model::TxPrevoutType;
54+
use crate::DEPOSIT_DUST_LIMIT;
5455

5556
/// The minimum incremental fee rate in sats per virtual byte for RBF
5657
/// transactions.
@@ -172,11 +173,14 @@ impl SbtcRequests {
172173
})
173174
.map(RequestRef::Withdrawal);
174175

175-
// Filter deposit requests based on two constraints:
176-
// 1. The user's max fee must be >= our minimum required fee for deposits
177-
// (based on fixed deposit tx size)
176+
// Filter deposit requests based on four constraints:
177+
// 1. The user's max fee must be >= our minimum required fee for
178+
// deposits (based on fixed deposit tx size)
178179
// 2. The deposit amount must be less than the per-deposit limit
179-
// 3. The total amount being minted must stay under the maximum allowed mintable amount
180+
// 3. The total amount being minted must stay under the maximum
181+
// allowed mintable amount
182+
// 4. The mint amount is above the deposit dust limit in the smart
183+
// contract.
180184
let minimum_deposit_fee = self.compute_minimum_fee(SOLO_DEPOSIT_TX_VSIZE);
181185
let max_mintable_cap = self.sbtc_limits.max_mintable_cap().to_sat();
182186
let per_deposit_cap = self.sbtc_limits.per_deposit_cap().to_sat();
@@ -191,6 +195,9 @@ impl SbtcRequests {
191195
} else {
192196
false
193197
};
198+
if req.amount.saturating_sub(minimum_deposit_fee) < DEPOSIT_DUST_LIMIT {
199+
return None;
200+
}
194201
if is_fee_valid && is_within_per_deposit_cap && is_within_max_mintable_cap {
195202
amount_to_mint += req.amount;
196203
Some(RequestRef::Deposit(req))
@@ -2849,6 +2856,63 @@ mod tests {
28492856
assert_eq!(total_amount, accepted_amount);
28502857
}
28512858

2859+
#[test_case(
2860+
create_deposit(
2861+
DEPOSIT_DUST_LIMIT + SOLO_DEPOSIT_TX_VSIZE as u64,
2862+
10_000,
2863+
0
2864+
),
2865+
true; "deposit amounts over the dust limit accepted")]
2866+
#[test_case(
2867+
create_deposit(
2868+
DEPOSIT_DUST_LIMIT + SOLO_DEPOSIT_TX_VSIZE as u64 - 1,
2869+
10_000,
2870+
0
2871+
),
2872+
false; "deposit amounts under the dust limit rejected")]
2873+
fn deposit_requests_respect_dust_limits(req: DepositRequest, is_included: bool) {
2874+
let outpoint = req.outpoint;
2875+
let public_key = XOnlyPublicKey::from_str(X_ONLY_PUBLIC_KEY1).unwrap();
2876+
2877+
// We use a fee rate of 1 to simplify the computation. The
2878+
// filtering done here uses a heuristic where we take the maximum
2879+
// fee that the user could pay, and subtract that amount from the
2880+
// deposit amount. The maximum fee that a user could pay is the
2881+
// SOLO_DEPOSIT_TX_VSIZE times the fee rate so with a fee rate of 1
2882+
// we should filter the request if the deposit amount is less than
2883+
// SOLO_DEPOSIT_TX_VSIZE + DEPOSIT_DUST_LIMIT.
2884+
let requests = SbtcRequests {
2885+
deposits: vec![create_deposit(2500000, 100000, 0), req],
2886+
withdrawals: vec![],
2887+
signer_state: SignerBtcState {
2888+
utxo: SignerUtxo {
2889+
outpoint: generate_outpoint(300_000, 0),
2890+
amount: 300_000_000,
2891+
public_key,
2892+
},
2893+
fee_rate: 1.0,
2894+
public_key,
2895+
last_fees: None,
2896+
magic_bytes: [0; 2],
2897+
},
2898+
num_signers: 11,
2899+
accept_threshold: 6,
2900+
sbtc_limits: SbtcLimits::default(),
2901+
};
2902+
2903+
// Let's construct the unsigned transaction and check to see if we
2904+
// include it in the deposit requests in the transaction.
2905+
let tx = requests.construct_transactions().unwrap().pop().unwrap();
2906+
let request_is_included = tx
2907+
.requests
2908+
.iter()
2909+
.filter_map(RequestRef::as_deposit)
2910+
.find(|req| req.outpoint == outpoint)
2911+
.is_some();
2912+
2913+
assert_eq!(request_is_included, is_included);
2914+
}
2915+
28522916
#[test]
28532917
fn test_construct_transactions_capped_by_number() {
28542918
// with 30 deposits and 30 withdrawals with 4 votes against each, we should generate 60 distinct transactions

signer/src/bitcoin/validation.rs

+42-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::storage::model::BitcoinWithdrawalOutput;
2323
use crate::storage::model::QualifiedRequestId;
2424
use crate::storage::model::SignerVotes;
2525
use crate::storage::DbRead;
26+
use crate::DEPOSIT_DUST_LIMIT;
2627
use crate::DEPOSIT_LOCKTIME_BLOCK_BUFFER;
2728

2829
use super::utxo::DepositRequest;
@@ -264,7 +265,7 @@ impl BitcoinPreSignRequest {
264265
.deposit_reports
265266
.get(&key)
266267
// This should never happen because we have already validated that we have all the reports.
267-
.ok_or(InputValidationResult::Unknown.into_error(btc_ctx))?;
268+
.ok_or_else(|| InputValidationResult::Unknown.into_error(btc_ctx))?;
268269
deposits.push((report.to_deposit_request(votes), report.clone()));
269270
}
270271

@@ -273,7 +274,7 @@ impl BitcoinPreSignRequest {
273274
.withdrawal_reports
274275
.get(id)
275276
// This should never happen because we have already validated that we have all the reports.
276-
.ok_or(WithdrawalValidationResult::Unknown.into_error(btc_ctx))?;
277+
.ok_or_else(|| WithdrawalValidationResult::Unknown.into_error(btc_ctx))?;
277278
withdrawals.push((report.to_withdrawal_request(votes), report.clone()));
278279
}
279280

@@ -508,6 +509,9 @@ impl SbtcReports {
508509
pub enum InputValidationResult {
509510
/// The deposit request passed validation
510511
Ok,
512+
/// The deposit request amount, less the fees, would be rejected from
513+
/// the smart contract during the complete-deposit contract call.
514+
MintAmountBelowDustLimit,
511515
/// The deposit request amount exceeds the allowed per-deposit cap.
512516
AmountTooHigh,
513517
/// The assessed fee exceeds the max-fee in the deposit request.
@@ -730,6 +734,10 @@ impl DepositRequestReport {
730734
return InputValidationResult::FeeTooHigh;
731735
}
732736

737+
if self.amount.saturating_sub(assessed_fee.to_sat()) < DEPOSIT_DUST_LIMIT {
738+
return InputValidationResult::MintAmountBelowDustLimit;
739+
}
740+
733741
// Let's check whether we rejected this deposit.
734742
match self.can_accept {
735743
Some(true) => (),
@@ -1063,6 +1071,38 @@ mod tests {
10631071
status: InputValidationResult::FeeTooHigh,
10641072
chain_tip_height: 2,
10651073
} ; "one-sat-too-high-fee-amount")]
1074+
#[test_case(DepositReportErrorMapping {
1075+
report: DepositRequestReport {
1076+
status: DepositConfirmationStatus::Confirmed(0, BitcoinBlockHash::from([0; 32])),
1077+
can_sign: Some(true),
1078+
can_accept: Some(true),
1079+
amount: TX_FEE.to_sat() + DEPOSIT_DUST_LIMIT - 1,
1080+
max_fee: TX_FEE.to_sat(),
1081+
lock_time: LockTime::from_height(DEPOSIT_LOCKTIME_BLOCK_BUFFER + 3),
1082+
outpoint: OutPoint::null(),
1083+
deposit_script: ScriptBuf::new(),
1084+
reclaim_script: ScriptBuf::new(),
1085+
signers_public_key: *sbtc::UNSPENDABLE_TAPROOT_KEY,
1086+
},
1087+
status: InputValidationResult::MintAmountBelowDustLimit,
1088+
chain_tip_height: 2,
1089+
} ; "one-sat-under-dust-amount")]
1090+
#[test_case(DepositReportErrorMapping {
1091+
report: DepositRequestReport {
1092+
status: DepositConfirmationStatus::Confirmed(0, BitcoinBlockHash::from([0; 32])),
1093+
can_sign: Some(true),
1094+
can_accept: Some(true),
1095+
amount: TX_FEE.to_sat() + DEPOSIT_DUST_LIMIT,
1096+
max_fee: TX_FEE.to_sat(),
1097+
lock_time: LockTime::from_height(DEPOSIT_LOCKTIME_BLOCK_BUFFER + 3),
1098+
outpoint: OutPoint::null(),
1099+
deposit_script: ScriptBuf::new(),
1100+
reclaim_script: ScriptBuf::new(),
1101+
signers_public_key: *sbtc::UNSPENDABLE_TAPROOT_KEY,
1102+
},
1103+
status: InputValidationResult::Ok,
1104+
chain_tip_height: 2,
1105+
} ; "at-dust-amount")]
10661106
#[test_case(DepositReportErrorMapping {
10671107
report: DepositRequestReport {
10681108
status: DepositConfirmationStatus::Confirmed(0, BitcoinBlockHash::from([0; 32])),

signer/src/lib.rs

+5
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ pub const MAX_REORG_BLOCK_COUNT: i64 = 10;
7878
/// per block.
7979
pub const MAX_TX_PER_BITCOIN_BLOCK: i64 = 25;
8080

81+
/// This is the dust limit for deposits in the sBTC smart contracts.
82+
/// Deposit amounts that is less than this amount will be rejected by the
83+
/// smart contract.
84+
pub const DEPOSIT_DUST_LIMIT: u64 = 546;
85+
8186
/// These are all build info variables. Many of them are set in build.rs.
8287
8388
/// The name of the binary that is being run,

signer/src/stacks/contracts.rs

+12-10
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ use crate::storage::model::BitcoinBlockRef;
5454
use crate::storage::model::BitcoinTxId;
5555
use crate::storage::model::ToLittleEndianOrder as _;
5656
use crate::storage::DbRead;
57+
use crate::DEPOSIT_DUST_LIMIT;
5758

5859
use super::api::StacksInteract;
5960

@@ -347,7 +348,7 @@ impl AsContractCall for CompleteDepositV1 {
347348
/// as an input.
348349
/// 5. That the recipients in the transaction matches that of the
349350
/// deposit request.
350-
/// 6. That the amount to mint does not exceed the deposit amount.
351+
/// 6. That the amount to mint is above the dust amount.
351352
/// 7. That the fee matches the expected assessed fee for the outpoint.
352353
/// 8. That the fee is less than the specified max-fee.
353354
/// 9. That the first input into the sweep transaction is the signers'
@@ -387,7 +388,7 @@ impl CompleteDepositV1 {
387388
/// of swept deposit requests.
388389
/// 5. That the recipients in the transaction matches that of the
389390
/// deposit request.
390-
/// 6. That the amount to mint does not exceed the deposit amount.
391+
/// 6. That the amount to mint is above the dust amount.
391392
/// 7. That the fee matches the expected assessed fee for the outpoint.
392393
/// 8. That the fee is less than the specified max-fee.
393394
///
@@ -418,10 +419,10 @@ impl CompleteDepositV1 {
418419
if &self.recipient != deposit_request.recipient.deref() {
419420
return Err(DepositErrorMsg::RecipientMismatch.into_error(req_ctx, self));
420421
}
421-
// 6. Check that the amount to mint does not exceed the deposit
422+
// 6. Check that the amount to mint is above the dust amount
422423
// amount.
423-
if self.amount > deposit_request.amount {
424-
return Err(DepositErrorMsg::InvalidMintAmount.into_error(req_ctx, self));
424+
if self.amount < DEPOSIT_DUST_LIMIT {
425+
return Err(DepositErrorMsg::AmountBelowDustLimit.into_error(req_ctx, self));
425426
}
426427
// 7. That the fee matches the expected assessed fee for the outpoint.
427428
if fee.to_sat() + self.amount != deposit_request.amount {
@@ -547,19 +548,20 @@ impl std::error::Error for DepositValidationError {
547548
/// transactions.
548549
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
549550
pub enum DepositErrorMsg {
551+
/// The smart contract has a dust limit which is used to rejects
552+
/// contract calls if the mint amount is below that limit. We check for
553+
/// that condition here before minting.
554+
#[error("the amount to mint is below the dust limit in the smart contract")]
555+
AmountBelowDustLimit,
550556
/// The smart contract deployer is fixed, so this should always match.
551-
#[error("The deployer in the transaction does not match the expected deployer")]
557+
#[error("the deployer in the transaction does not match the expected deployer")]
552558
DeployerMismatch,
553559
/// The fee paid to the bitcoin miners exceeded the max fee.
554560
#[error("fee paid to the bitcoin miners exceeded the max fee")]
555561
FeeTooHigh,
556562
/// The supplied fee does not match what is expected.
557563
#[error("the supplied fee does not match what is expected")]
558564
IncorrectFee,
559-
/// The amount to mint must not exceed the amount in the deposit
560-
/// request.
561-
#[error("amount to mint exceeded the amount in the deposit request")]
562-
InvalidMintAmount,
563565
/// The deposit outpoint is missing from the indicated sweep
564566
/// transaction.
565567
#[error("deposit outpoint is missing from the indicated sweep transaction")]

signer/src/testing/block_observer.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ impl TestHarness {
103103
vout: Vec::new(),
104104
block_hash: response
105105
.block_hash
106-
.unwrap_or(bitcoin::BlockHash::all_zeros()),
106+
.unwrap_or_else(bitcoin::BlockHash::all_zeros),
107107
confirmations: 0,
108108
block_time: 0,
109109
};

0 commit comments

Comments
 (0)