Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
fa6f5ec
first draft
olga24912 Mar 27, 2026
eb63441
add standard refund diagram
olga24912 Mar 30, 2026
b5d9ed1
add refund requests tests
olga24912 Mar 30, 2026
edf58ef
add fail diagram
olga24912 Mar 30, 2026
b2b65ee
add refund tests
olga24912 Mar 30, 2026
bc73080
add psbt for refund
olga24912 Mar 30, 2026
3113f71
test already verify deposit
olga24912 Mar 30, 2026
c2a2cc9
fix tests
olga24912 Mar 30, 2026
211cfa0
fix tests: execute refund by user
olga24912 Apr 1, 2026
c0ca652
fix execute refund
olga24912 Apr 1, 2026
71ae63b
small fixes
olga24912 Apr 1, 2026
f8324d4
add no withdraw flow
olga24912 Apr 3, 2026
106268e
add check for no verify_withdraw for refund
olga24912 Apr 3, 2026
8f4f045
add verify_refund
olga24912 Apr 3, 2026
bc3ae0b
test race deposit flow
olga24912 Apr 3, 2026
e1fd99e
add tests refund after deposit fails
olga24912 Apr 3, 2026
1753f3c
add tests refund after deposit fails
olga24912 Apr 3, 2026
8d23beb
tests execute refund
olga24912 Apr 3, 2026
99206e4
execute refund test
olga24912 Apr 3, 2026
710d7d3
double request flow
olga24912 Apr 3, 2026
181b003
change deposit test
olga24912 Apr 3, 2026
afaa15c
add safe deposit tests
olga24912 Apr 3, 2026
fcb4f46
fix lint
olga24912 Apr 6, 2026
252f996
fix lint
olga24912 Apr 6, 2026
20a4767
fix CI
olga24912 Apr 6, 2026
0620f00
Merge branch 'omni-main' into btc_refund
olga24912 Apr 6, 2026
a1122b4
promise-result-checked
olga24912 Apr 6, 2026
c294518
fix lint
olga24912 Apr 6, 2026
1c9c272
fix tests
olga24912 Apr 6, 2026
5339354
add comments to public methods
olga24912 Apr 6, 2026
724da78
add option to provide gas fee
olga24912 Apr 6, 2026
33a97aa
rename
olga24912 Apr 6, 2026
fea99fd
hide refund_timelock_sec for zcash
olga24912 Apr 6, 2026
865019b
remove as slice
olga24912 Apr 6, 2026
2b6d4b0
use deposit amount for confirmations number
olga24912 Apr 6, 2026
f1880ee
reject refund by anyone
olga24912 Apr 7, 2026
403ff11
fix burn_amount
olga24912 Apr 7, 2026
28397dd
any refund address
olga24912 Apr 7, 2026
80027f9
fmt
olga24912 Apr 7, 2026
532eb0f
fix tests
olga24912 Apr 7, 2026
22d199e
fix comment
olga24912 Apr 10, 2026
e764521
reduce refund cost
olga24912 Apr 10, 2026
f1f3ed5
fmt
olga24912 Apr 10, 2026
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
128 changes: 128 additions & 0 deletions contracts/satoshi-bridge/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,134 @@ impl Contract {
}
}

#[cfg(not(feature = "zcash"))]
#[near]
impl Contract {
// ── Refund API (Bitcoin only) ──

/// Submit a refund request for a deposit that was never finalized via `verify_deposit` or `safe_verify_deposit`.
/// The BTC transaction is verified through the Light Client to prove the deposit exists.
/// After the timelock period, anyone can call `execute_refund` to initiate the return.
///
/// # Arguments
///
/// * `deposit_msg` - The original deposit message. If `deposit_msg.refund_address` is set,
/// it must match the provided `refund_address`.
/// * `refund_address` - BTC address to send the refund to. If `deposit_msg.refund_address`
/// is `None`, this value is used directly.
/// * `tx_bytes` - BTC transaction bytes proving the deposit.
/// * `vout` - Output index of the deposit in the transaction.
/// * `tx_block_blockhash` - Block hash containing the transaction.
/// * `tx_index` - Transaction index within the block.
/// * `merkle_proof` - Merkle proof for Light Client verification.
/// * `gas_fee` - Optional custom gas fee. Only DAO or Operator can set this.
/// If `None`, the default `config.max_btc_gas_fee` is used during `execute_refund`.
#[allow(clippy::too_many_arguments)]
#[pause(except(roles(Role::DAO)))]
pub fn request_refund(
&mut self,
deposit_msg: DepositMsg,
refund_address: String,
tx_bytes: Vec<u8>,
vout: usize,
tx_block_blockhash: String,
tx_index: u64,
merkle_proof: Vec<String>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we going to add coinbase proof here?
I would like. to refactor it and create specific struct for the proof arguments so it can be reused between methods.
Also please verify the performance of this call, maybe we can optimize it by using borsh, or at least make the tx_bytes readable by using hex or baseXX

gas_fee: Option<U128>,
) -> Promise {
if gas_fee.is_some() {
let caller = env::predecessor_account_id();
require!(
self.acl_has_role(Role::DAO.into(), caller.clone())
|| self.acl_has_role(Role::Operator.into(), caller),
"Only DAO or Operator can specify custom gas_fee"
);
}
self.internal_request_refund(
deposit_msg,
refund_address,
tx_bytes,
vout,
tx_block_blockhash,
tx_index,
merkle_proof,
gas_fee.map(|v| v.0),
)
}

/// Reject a pending refund request.
/// - DAO or Operator can reject any request.
/// - Anyone can reject a request if the UTXO has already been verified via `verify_deposit`.
///
/// # Arguments
///
/// * `utxo_storage_key` - The UTXO key identifying the refund request (`{tx_id}@{vout}`).
pub fn reject_refund(&mut self, utxo_storage_key: String) {
let caller = env::predecessor_account_id();
let is_privileged = self.acl_has_role(Role::DAO.into(), caller.clone())
|| self.acl_has_role(Role::Operator.into(), caller);
let is_already_deposited = self
.data()
.verified_deposit_utxo
.contains(&utxo_storage_key);
require!(
is_privileged || is_already_deposited,
"Only DAO/Operator can reject, or UTXO must be already verified via deposit"
);
self.internal_reject_refund(utxo_storage_key);
}

/// Execute a refund after the timelock has passed. Builds a BTC transaction
/// that sends the deposit UTXO back to the `refund_address` specified in the original
/// `DepositMsg`. Creates a `BTCPendingInfo` entry for the MPC sign pipeline.
/// Marks the UTXO in `verified_deposit_utxo` to prevent future `verify_deposit`.
///
/// # Arguments
///
/// * `utxo_storage_key` - The UTXO key identifying the refund request (`{tx_id}@{vout}`).
#[payable]
#[pause(except(roles(Role::DAO)))]
pub fn execute_refund(&mut self, utxo_storage_key: String) {
require!(
env::attached_deposit() >= self.required_balance_for_execute_refund(),
"Insufficient deposit for storage"
);
self.internal_execute_refund(utxo_storage_key);
}

/// Verify that the refund BTC transaction has been confirmed on the Bitcoin network.
/// Cleans up the `BTCPendingInfo` after successful verification.
///
/// # Arguments
///
/// * `tx_id` - Transaction ID of the confirmed refund transaction.
/// * `tx_block_blockhash` - Block hash containing the transaction.
/// * `tx_index` - Transaction index within the block.
/// * `merkle_proof` - Merkle proof for Light Client verification.
#[pause(except(roles(Role::DAO)))]
pub fn verify_refund_finalize(
&mut self,
tx_id: String,
tx_block_blockhash: String,
tx_index: u64,
merkle_proof: Vec<String>,
) -> Promise {
let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id);
btc_pending_info.assert_refund_pending_verify_tx();
require!(
btc_pending_info.tx_bytes_with_sign.is_some(),
"Missing tx_bytes_with_sign"
);
self.internal_verify_refund_finalize(
tx_id,
tx_block_blockhash,
tx_index,
merkle_proof,
btc_pending_info,
)
}
}

impl Contract {
pub fn create_active_utxo_management_pending_info(
&mut self,
Expand Down
8 changes: 8 additions & 0 deletions contracts/satoshi-bridge/src/api/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,12 @@ impl Contract {
);
self.internal_mut_config().unhealthy_utxo_amount = unhealthy_utxo_amount.0;
}

#[cfg(not(feature = "zcash"))]
#[payable]
#[access_control_any(roles(Role::DAO))]
pub fn set_refund_timelock_sec(&mut self, refund_timelock_sec: u64) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The number of setters has become huge, can we combine some of them under one setter, eg set_config?

assert_one_yocto();
self.internal_mut_config().refund_timelock_sec = refund_timelock_sec;
}
}
1 change: 1 addition & 0 deletions contracts/satoshi-bridge/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#[allow(clippy::too_many_arguments)]
mod bridge;
mod chain_signatures;
mod management;
Expand Down
7 changes: 7 additions & 0 deletions contracts/satoshi-bridge/src/api/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,11 @@ impl Contract {
pub fn required_balance_for_safe_deposit(&self) -> NearToken {
REQUIRED_BALANCE_FOR_DEPOSIT
}

#[cfg(not(feature = "zcash"))]
pub fn required_balance_for_execute_refund(&self) -> NearToken {
// execute_refund uses ~700 bytes of storage (BTCPendingInfo + Account + verified_deposit_utxo)
// At 0.00001 NEAR/byte, that's ~0.007 NEAR. We use 0.01 NEAR as a safe margin.
NearToken::from_millinear(10)
}
}
19 changes: 18 additions & 1 deletion contracts/satoshi-bridge/src/btc_pending_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub enum PendingInfoState {
ActiveUtxoManagementOriginal(OriginalState),
ActiveUtxoManagementRbf(RbfState),
ActiveUtxoManagementCancelRbf(RbfState),
Refund(OriginalState),
}

impl PendingInfoState {
Expand All @@ -84,6 +85,7 @@ impl PendingInfoState {
PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_sign(),
PendingInfoState::ActiveUtxoManagementRbf(state) => state.assert_pending_sign(),
PendingInfoState::ActiveUtxoManagementCancelRbf(state) => state.assert_pending_sign(),
PendingInfoState::Refund(state) => state.assert_pending_sign(),
}
}
pub fn assert_pending_verify(&self) {
Expand All @@ -94,6 +96,7 @@ impl PendingInfoState {
PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_verify(),
PendingInfoState::ActiveUtxoManagementRbf(state) => state.assert_pending_verify(),
PendingInfoState::ActiveUtxoManagementCancelRbf(state) => state.assert_pending_verify(),
PendingInfoState::Refund(state) => state.assert_pending_verify(),
}
}
}
Expand Down Expand Up @@ -157,6 +160,13 @@ impl BTCPendingInfo {
}
}

pub fn assert_refund_pending_verify_tx(&self) {
match self.state.borrow() {
PendingInfoState::Refund(state) => state.assert_pending_verify(),
_ => env::panic_str("Not refund related tx"),
}
}

pub fn assert_active_utxo_management_related_pending_verify_tx(&self) {
match self.state.borrow() {
PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_verify(),
Expand Down Expand Up @@ -222,6 +232,9 @@ impl BTCPendingInfo {
PendingInfoState::ActiveUtxoManagementCancelRbf(state) => {
state.stage = PendingInfoStage::PendingVerify;
}
PendingInfoState::Refund(state) => {
state.stage = PendingInfoStage::PendingVerify;
}
}
}

Expand All @@ -243,6 +256,9 @@ impl BTCPendingInfo {
PendingInfoState::ActiveUtxoManagementCancelRbf(state) => {
state.stage = PendingInfoStage::PendingBurn;
}
PendingInfoState::Refund(state) => {
state.stage = PendingInfoStage::PendingBurn;
}
}
}

Expand Down Expand Up @@ -393,7 +409,7 @@ impl Contract {

pub fn generate_btc_pending_sign_id(payload_preimages: &[Vec<u8>]) -> String {
let hash_bytes = env::sha256_array(
&payload_preimages
payload_preimages
.iter()
.flatten()
.copied()
Expand Down Expand Up @@ -457,6 +473,7 @@ mod tests {
post_actions: None,
extra_msg: None,
safe_deposit: None,
refund_address: None,
};

let path = get_deposit_path(&deposit_msg);
Expand Down
3 changes: 3 additions & 0 deletions contracts/satoshi-bridge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ pub struct Config {
pub unhealthy_utxo_amount: u64,
#[cfg(feature = "zcash")]
pub expiry_height_gap: u32,
// Timelock in seconds before a refund request can be executed.
#[cfg(not(feature = "zcash"))]
pub refund_timelock_sec: u64,
}

impl Config {
Expand Down
3 changes: 3 additions & 0 deletions contracts/satoshi-bridge/src/deposit_msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub struct DepositMsg {
// If this field is present, the legacy post_actions field must be None
#[serde(skip_serializing_if = "Option::is_none")]
pub safe_deposit: Option<SafeDepositMsg>,
// BTC address for refund if deposit is never finalized.
#[serde(skip_serializing_if = "Option::is_none")]
pub refund_address: Option<String>,
}

#[near(serializers = [json])]
Expand Down
15 changes: 15 additions & 0 deletions contracts/satoshi-bridge/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ pub enum Event<'a> {
index: Option<usize>,
err_msg: String,
},
RefundRequested {
deposit_msg: DepositMsg,
utxo_storage_key: String,
amount: U128,
refund_address: String,
gas_fee: U128,
},
RefundRejected {
utxo_storage_key: String,
},
RefundExecuted {
utxo_storage_key: String,
amount: U128,
refund_address: String,
},
}

impl Event<'_> {
Expand Down
10 changes: 10 additions & 0 deletions contracts/satoshi-bridge/src/legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ impl From<ContractDataV0> for ContractData {
acc_claimed_protocol_fee,
cur_reserved_protocol_fee,
acc_protocol_fee_for_gas,
#[cfg(not(feature = "zcash"))]
refund_requests: IterableMap::new(StorageKey::RefundRequests),
}
}
}
Expand Down Expand Up @@ -190,6 +192,8 @@ impl From<ConfigV0> for Config {
unhealthy_utxo_amount: 1000,
#[cfg(feature = "zcash")]
expiry_height_gap: 1000,
#[cfg(not(feature = "zcash"))]
refund_timelock_sec: 14 * 24 * 3600, // 2 weeks
}
}
}
Expand Down Expand Up @@ -330,6 +334,8 @@ impl From<ConfigV1> for Config {
unhealthy_utxo_amount,
#[cfg(feature = "zcash")]
expiry_height_gap,
#[cfg(not(feature = "zcash"))]
refund_timelock_sec: 14 * 24 * 3600, // 2 weeks
}
}
}
Expand Down Expand Up @@ -393,6 +399,8 @@ impl From<ContractDataV1> for ContractData {
acc_claimed_protocol_fee,
cur_reserved_protocol_fee,
acc_protocol_fee_for_gas,
#[cfg(not(feature = "zcash"))]
refund_requests: IterableMap::new(StorageKey::RefundRequests),
}
}
}
Expand Down Expand Up @@ -467,6 +475,8 @@ impl From<ContractDataV2> for ContractData {
acc_claimed_protocol_fee,
cur_reserved_protocol_fee,
acc_protocol_fee_for_gas,
#[cfg(not(feature = "zcash"))]
refund_requests: IterableMap::new(StorageKey::RefundRequests),
}
}
}
10 changes: 10 additions & 0 deletions contracts/satoshi-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub mod nbtc;
pub mod network;
pub mod psbt;
pub mod rbf;
#[cfg(not(feature = "zcash"))]
pub mod refund;
pub mod token_transfer;
#[cfg(test)]
mod unit;
Expand All @@ -54,6 +56,8 @@ pub use crate::json_utils::*;
pub use crate::legacy::*;
pub use crate::nbtc::*;
pub use crate::rbf::*;
#[cfg(not(feature = "zcash"))]
pub use crate::refund::*;
pub use crate::token_transfer::*;
pub use crate::utils::*;
pub use crate::utxo::*;
Expand Down Expand Up @@ -92,6 +96,8 @@ enum StorageKey {
LostFound,
PostActionMsgTemplates,
ExtraMsgRelayerWhiteList,
#[cfg(not(feature = "zcash"))]
RefundRequests,
}

#[derive(AccessControlRole, Deserialize, Serialize, Copy, Clone)]
Expand Down Expand Up @@ -125,6 +131,8 @@ pub struct ContractData {
pub acc_claimed_protocol_fee: u128,
pub cur_reserved_protocol_fee: u128,
pub acc_protocol_fee_for_gas: u128,
#[cfg(not(feature = "zcash"))]
pub refund_requests: IterableMap<String, VRefundRequest>,
}

#[near(serializers = [borsh])]
Expand Down Expand Up @@ -186,6 +194,8 @@ impl Contract {
),
post_action_msg_templates: IterableMap::new(StorageKey::PostActionMsgTemplates),
lost_found: IterableMap::new(StorageKey::LostFound),
#[cfg(not(feature = "zcash"))]
refund_requests: IterableMap::new(StorageKey::RefundRequests),
acc_collected_protocol_fee: 0,
cur_available_protocol_fee: 0,
acc_claimed_protocol_fee: 0,
Expand Down
Loading