Develop Your First BTCFi DApp in REE
This document demonstrates how to develop a REE BTCFi DApp implementing a typical lending scenario on the blockchain. It includes developing both an Exchange backend (canister) and an Exchange Frontend with core functionality. If you want to learn how to develop an Exchange Client to integrate with an existing Exchange, please refer to the RichSwap integration documentation.
For a more complete demo application and source code, please refer to:
- Demo: https://ree-lending-demo.vercel.app/
- GitHub: https://github.com/octopus-network/ree-lending-demo
Prerequisites
Before you begin, ensure you meet the following requirements:
- Basic Knowledge: Understand the fundamentals of developing canisters using Rust and basic frontend development.
- Development Environment: Have the Rust toolchain and the DFINITY Canister SDK (dfx) installed.
This document will not cover the installation process in detail.
Getting Started
1. Create a new project
We can use the dfx
tool to quickly create a project template with a Rust backend canister and a React frontend:
dfx new --type rust --frontend react ree-demo-exchange
Executing this command generates the following project structure:
./ree-demo-exchange
├── src
│ ├── ree-demo-exchange-backend # Backend Canister project (Rust)
│ └── ree-demo-exchange-frontend # Frontend project (React)
├── dfx.json # Dfx configuration file
└── ... # Other configuration files
ree-demo-exchange-backend
: This directory contains the Rust canister project where we will write the core logic.ree-demo-exchange-frontend
: This directory contains the React project for the frontend user interface.
2. Define the core data structure: pool
First, we need to define the core data structure representing a lending pool, Pool
. A Pool
primarily holds assets and records its state change history.
Create a new file pool.rs
in the ree-demo-exchange-backend/src
directory and define the following structures (code provided as a reference snippet):
// Pool represents the basic structure of a lending pool
// It maintains the pool's state history, metadata, and address information
pub struct Pool {
pub states: Vec<PoolState>, // chain of historical pool states
pub meta: CoinMeta,
pub pubkey: Pubkey,
pub addr: String, // pool address (cached to avoid re-acquisition costs)
}
impl Pool {
pub fn base_id(&self) -> CoinId {
self.meta.id
}
// Assigns a unique derivation path to each pool based on its base asset ID
// This ensures different pools have different addresses and use different private keys to hold assets
pub fn derivation_path(&self) -> Vec<Vec<u8>> {
vec![self.base_id().to_string().as_bytes().to_vec()]
}
}
Explanation:
- The
Pool
struct stores metadata about the associated asset (CoinMeta
), the pool's public key (Pubkey
), a cached pool address (addr
), and a list of historical states (states
). - The
derivation_path
method generates a unique path based on the pool's base asset id (CoinId
). This is crucial for generating deterministic addresses using Chain-key technology, ensuring different pools have distinct addresses controlled by different underlying keys managed by the IC.
3. Manage pool state: PoolState
We need a way to represent the state of a pool at a specific point in time, usually after a transaction.
Define the PoolState
struct within pool.rs
(code provided as a reference snippet):
// PoolState represents the state of a pool
// A new PoolState is created and added to the Pool's states chain after each transaction
pub struct PoolState {
pub id: Option<Txid>, // transaction id that created this state (none for initial state)
pub nonce: u64, // incremental counter to prevent replay attacks
pub utxo: Option<Utxo>, // the utxo holding the pool's assets
}
We can then implement methods on the Pool
struct to manage these states (code provided as a reference snippet):
impl Pool {
// Adds a new PoolState to the chain after a transaction is executed
pub(crate) fn commit(&mut self, state: PoolState) {
self.states.push(state);
}
}
Explanation:
PoolState
records the state after a specific transaction (Txid
), including the UTXO holding the pool's assets at that time and anonce
to prevent replay attacks.- The
finalize
method is used after a transaction is confirmed on the underlying blockchain. It sets the state corresponding to the confirmedtxid
as the new base state and removes older states to save storage. - The
commit
method appends a newPoolState
to the history, typically after a transaction has been submitted but not yet finalized.
4. Store pool data
We need a persistent way to store all the created Pool
instances, ensuring data survives canister upgrades. The IC provides StableBTreeMap
for this purpose.
Define the storage in ree-demo-exchange-backend/src/lib.rs
(code provided as a reference snippet):
// LENDING_POOLS stores all lending pools
// It's a mapping from pool_address (String) to Pool information
static LENDING_POOLS: RefCell<StableBTreeMap<String, Pool, Memory>> = RefCell::new(
StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))),
)
);
Explanation:
LENDING_POOLS
is declared as a staticRefCell
containing aStableBTreeMap
.- This map uses the
Pool
's address (String) as the key and thePool
struct as the value. - It is initialized using memory obtained from a
MEMORY_MANAGER
(typically defined usingthread_local!
), ensuring the data resides in stable memory.
5. Initialize a demo pool
For demonstration purposes, it's useful to have a function that initializes a sample Pool
when the canister is deployed. This function is restricted to the canister's controller.
Create a new file lending.rs
in ree-demo-exchange-backend/src
and implement the init_pool
function (code provided as a reference snippet):
#[update]
// init_pool creates a demonstration lending pool when the exchange is deployed
// This pool allows users to borrow BTC satoshis at a 1:1 ratio by depositing RICH tokens as collateral
async fn init_pool() -> Result<(), String> {
let caller = ic_cdk::api::caller();
if !ic_cdk::api::is_controller(&caller) {
return Err("Not authorized".to_string());
}
let id = CoinId::rune(72798, 1058);
let meta = CoinMeta {
id,
symbol: "HOPE•YOU•GET•RICH".to_string(),
min_amount: 1,
};
// Request a pool address from the REE system
let (untweaked, _, addr) = request_ree_pool_address(
crate::SCHNORR_KEY_NAME,
vec![id.to_string().as_bytes().to_vec()],
Network::Testnet4,
)
.await?;
// Initialize the pool with empty state
let pool = crate::Pool {
meta,
pubkey: untweaked.clone(),
addr: addr.to_string(),
states: vec![],
};
// Store the pool in the LENDING_POOLS storage
crate::LENDING_POOLS.with_borrow_mut(|p| {
p.insert(addr.to_string(), pool);
});
Ok(())
}
Explanation:
init_pool
is an#[update]
method because it modifies canister state (LENDING_POOLS
) and makes anawait
call.- It first checks if the caller is a controller.
- It defines metadata for a sample Rune asset (
HOPE•YOU•GET•RICH
). - Key Step: It calls
request_ree_pool_address
. This represents an interaction with the IC's Chain-key signing service to derive a Bitcoin-compatible address.- Important: Real Chain-key calls are asynchronous (
async
) and cost Cycles. - The derived address (
addr
) and public key (pubkey
) are deterministic for a given canister ID, key name, and derivation path. - Therefore, this call is typically made only once when creating the
Pool
, and the resulting address and public key are cached within thePool
struct to avoid repeated costs. ThePool
effectively "holds" the private key corresponding to this address, managed securely by the IC.
- Important: Real Chain-key calls are asynchronous (
- It creates a new
Pool
instance with an empty initial state (states: vec![]
). - It stores the newly created
pool
in theLENDING_POOLS
stable map.
6. Implement required exchange methods
An Exchange canister interacting with a framework like REE usually needs to implement a standard set of interface methods. The five required methods mentioned are: get_pool_list
, get_pool_info
, rollback_tx
, new_block
, and execute_tx
.
Let's implement the first three query methods. Create a new file exchange.rs
in ree-demo-exchange-backend/src
(code provided as reference snippets):
#[query]
// Returns a list of all lending pools
// Each pool entry contains its name (symbol) and address
pub fn get_pool_list() -> GetPoolListResponse {
let pools = crate::get_pools(); // assumes a helper function get_pools() exists
pools
.iter()
.map(|p| PoolBasic {
name: p.meta.symbol.clone(),
address: p.addr.clone(),
})
.collect()
}
#[query]
// Returns detailed information about a specific pool identified by its address
pub fn get_pool_info(args: GetPoolInfoArgs) -> GetPoolInfoResponse {
let GetPoolInfoArgs { pool_address } = args;
let p = crate::get_pool(&pool_address)?; // assumes a helper function get_pool() exists
Some(PoolInfo {
key: p.pubkey.clone(),
name: p.meta.symbol.clone(),
key_derivation_path: vec![p.meta.id.to_bytes()], // simplified example path
address: p.addr.clone(),
nonce: p.states.last().map(|s| s.nonce).unwrap_or_default(),
btc_reserved: p.states.last().map(|s| s.btc_supply()).unwrap_or_default(), // assumes btc_supply() exists
coin_reserved: p
.states
.last()
.map(|s| {
vec![CoinBalance {
id: p.meta.id,
value: s.rune_supply() as u128, // assumes rune_supply() exists
}]
})
.unwrap_or_default(),
utxos: p
.states
.last()
.and_then(|s| s.utxo.clone())
.map(|utxo| vec![utxo])
.unwrap_or_default(),
attributes: p.attrs(), // assumes attrs() exists
})
}
Explanation:
get_pool_list
: A#[query]
method (read-only, fast) that iterates through the stored pools (assuming a helper likeget_pools()
) and returns a list of basic pool information (PoolBasic
).get_pool_info
: A#[query]
method that takes apool_address
, looks up the correspondingPool
(assuming a helper likeget_pool()
), and returns detailedPoolInfo
if found. It extracts data likenonce
, asset reserves, andutxos
from the latestPoolState
.
7. Implementing the Deposit Action
We first implement the necessary methods in the canister for the deposit action, including pre_deposit()
and execute_tx
.
pre_deposit
receives the quantity of assets the user wants to deposit. This method typically needs to return the information required for the deposit transaction corresponding to the user's input. In this example, we only accept any amount of BTC assets deposited by the user, so we can ignore the user's input and only return the pool's UTXO and nonce.
We can add the following method in lending.rs
:
#[query]
// pre_deposit queries the information needed to build a deposit transaction
// by specifying the target pool address and deposit amount
pub fn pre_deposit(
pool_address: String,
amount: CoinBalance,
) -> Result<DepositOffer, ExchangeError> {
if amount.value < CoinMeta::btc().min_amount {
return Err(ExchangeError::TooSmallFunds);
}
let pool = crate::get_pool(&pool_address).ok_or(ExchangeError::InvalidPool)?;
let state = pool.states.last().clone();
Ok(DepositOffer {
pool_utxo: state.map(|s| s.utxo.clone()).flatten(),
nonce: state.map(|s| s.nonce).unwrap_or_default(),
})
}
Next, we implement execute_tx
. This method accepts the PSBT constructed by the frontend and validated by the orchestrator, along with the user's intention set. There are three steps to be done in this method:
- Validate that the intention set meets the pool's requirements. In this example, it should be that the user transfers out a certain amount of BTC, and the pool receives a certain amount of BTC (i.e., the deposit process). Other necessary validations should also be performed.
- Call the
ree_pool_sign
method provided by theree-types
library to sign the UTXO held by the pool, releasing the assets. - Execute this transaction and modify the exchange pool state. The user will then be able to see the result of the deposit. A reference code looks like this:
#[update(guard = "ensure_testnet4_orchestrator")]
// Accepts transaction execution requests from the orchestrator
// Verifies the submitted PSBT (Partially Signed Bitcoin Transaction)
// If validation passes, signs the pool's UTXOs and updates the exchange pool state
// Only the orchestrator can call this function (ensured by the guard)
pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse {
let ExecuteTxArgs {
psbt_hex,
txid,
intention_set,
intention_index,
zero_confirmed_tx_queue_length: _zero_confirmed_tx_queue_length,
} = args;
// Decode and deserialize the PSBT
let raw = hex::decode(&psbt_hex).map_err(|_| "invalid psbt".to_string())?;
let mut psbt = Psbt::deserialize(raw.as_slice()).map_err(|_| "invalid psbt".to_string())?;
// Extract the intention details
let intention = intention_set.intentions[intention_index as usize].clone();
let Intention {
exchange_id: _,
action: _,
action_params: _,
pool_address,
nonce,
pool_utxo_spend,
pool_utxo_receive,
input_coins,
output_coins,
} = intention;
// Get the pool from storage
let pool = crate::LENDING_POOLS
.with_borrow(|m| m.get(&pool_address).expect("already checked in pre_*; qed"));
// Process the transaction based on the action type
match intention.action.as_ref() {
"deposit" => {
// Validate the deposit transaction and get the new pool state
let (new_state, consumed) = pool
.validate_deposit(
txid,
nonce,
pool_utxo_spend,
pool_utxo_receive,
input_coins,
output_coins,
)
.map_err(|e| e.to_string())?;
// Sign the UTXO if there's an existing one to spend
if let Some(ref utxo) = consumed {
ree_pool_sign(
&mut psbt,
utxo,
crate::SCHNORR_KEY_NAME,
pool.derivation_path(),
)
.await
.map_err(|e| e.to_string())?;
}
// Update the pool with the new state
crate::LENDING_POOLS.with_borrow_mut(|m| {
let mut pool = m
.get(&pool_address)
.expect("already checked in pre_deposit; qed");
pool.commit(new_state);
m.insert(pool_address.clone(), pool);
});
}
_ => {
return Err("invalid method".to_string());
}
}
// Return the serialized PSBT with the exchange's signatures
Ok(psbt.serialize_hex())
}
8. Implementing the Deposit Action on the Frontend
Now, let's outline how to implement the user deposit action on the frontend, interacting with the canister methods defined in Section 7. We'll use the inquiry/invoke pattern:
The inquiry step:
- The frontend invokes the
pre_deposit
query method on the Exchange canister, passing the target pool address and the desired deposit amount. - The canister returns the necessary information (like the pool's current UTXO and nonce) needed to construct the transaction.
Frontend Example (React
useEffect
hook):// Simplified example of calling pre_deposit when the input amount changes
useEffect(() => {
if (!Number(debouncedInputAmount)) {
return;
}
const btcAmount = parseCoinAmount(debouncedInputAmount, BITCOIN);
setIsQuoting(true);
// Call the exchange canister's pre_deposit method
lendingActor
.pre_deposit(pool.address, { id: BITCOIN.id, value: BigInt(btcAmount) })
.then((res: any) => {
if (res.Ok) {
// Store the returned offer (pool UTXO, nonce)
setDepositOffer(res.Ok);
}
})
.finally(() => {
setIsQuoting(false);
});
}, [debouncedInputAmount]); // Re-run when amount changes- The frontend invokes the
Transaction construction & signing:
- The frontend uses the parameters received from
pre_deposit
(like the pool's UTXO and nonce) and the user's input (deposit amount, their UTXOs) to construct a PSBT. - The frontend prompts the user to sign the PSBT inputs belonging to them using their Bitcoin wallet (e.g., UniSat, Xverse).
Explanation on PSBT Construction:
Constructing the PSBT currently involves some complexity, requiring developers to understand the UTXO calculation model. The specific implementation for this deposit example can be found in the REE Lending Demo repository: DepositContent.tsx#L116-L329.
We plan to abstract this implementation into a library method in the future to simplify development. Here's the basic principle behind constructing this PSBT:
A Bitcoin transaction essentially destroys a set of input UTXOs and creates a set of output UTXOs. In our deposit example, we need to construct a transaction where:
- Inputs: Combine the pool's current BTC UTXO (obtained via
pre_deposit
) and the user's UTXO(s) used to pay for the deposit (obtained from the user's wallet). - Outputs: Create new UTXOs:
- One UTXO belonging to the pool, with a BTC balance increased by the deposited amount.
- One UTXO belonging to the user (change), with a BTC amount equal to the user's input UTXO(s) minus the deposit amount and minus the transaction fee.
This process effectively transfers the deposited BTC from the user to the pool while accounting for the network transaction fee.
- The frontend uses the parameters received from
Invoke:
- The frontend sends the user-signed PSBT along with an
IntentionSet
to theinvoke
method on the REE Orchestrator canister. - The Orchestrator validates the PSBT and the
IntentionSet
, calls the correspondingexecute_tx
method on the specified Exchange canister (as described in Section 7), gathers the exchange's signature on the PSBT, broadcasts the final transaction to the Bitcoin network, and eventually returns the Bitcoin transaction ID (txid
).
Understanding the
invoke
Call andIntentionSet
:The Orchestrator's
invoke
function is the main entry point for executing actions within REE. It expectsInvokeArgs
:// Arguments for the Orchestrator's invoke function
pub struct InvokeArgs {
pub psbt_hex: String, // User-signed PSBT (hex encoded)
pub intention_set: IntentionSet, // Describes the user's and exchange's actions
}The crucial part is the
IntentionSet
, which details what the transaction aims to achieve:// Defines the overall goal of the transaction
pub struct IntentionSet {
pub initiator_address: String, // User's address (for change/refunds)
pub tx_fee_in_sats: u64, // Proposed Bitcoin transaction fee
pub intentions: Vec<Intention>, // List of specific actions (usually one)
}
// Defines a single action within the transaction
pub struct Intention {
pub exchange_id: String, // Target Exchange canister ID
pub action: String, // Action name (e.g., "deposit")
pub action_params: String, // Optional extra parameters for the exchange
pub pool_address: String, // Target Pool address within the exchange
pub nonce: u64, // Nonce obtained from pre_deposit
pub pool_utxo_spend: Vec<String>, // Pool UTXOs being spent (if any)
pub pool_utxo_receive: Vec<String>, // New UTXOs the pool will receive
pub input_coins: Vec<InputCoin>, // Coins the user is spending
pub output_coins: Vec<OutputCoin>, // Coins the user might receive (e.g., change)
}
// Represents coins being spent
pub struct InputCoin {
pub from: String, // Owner address (usually the user)
pub coin: CoinBalance, // Asset type and amount
}
// Represents coins being received
pub struct OutputCoin {
pub to: String, // Receiver address
pub coin: CoinBalance, // Asset type and amount
}For our deposit example, the
Intention
would specify:action
: "deposit"exchange_id
: The ID of our lending exchange canister.pool_address
: The address of the specific BTC pool.nonce
: The nonce received frompre_deposit
.pool_utxo_spend
: Often empty for a simple deposit, unless the pool consolidates funds.pool_utxo_receive
: The new UTXO representing the deposited amount combined with existing pool funds.input_coins
: The BTC the user is sending from their wallet.output_coins
: Usually empty for a simple deposit, unless there's change involved.
The Orchestrator validates fields like
exchange_id
andpool_address
and ensures theIntentionSet
aligns with the PSBT data before calling the Exchange'sexecute_tx
.Frontend Example (React
onSubmit
function):// Simplified example of constructing IntentionSet and calling invoke
const onSubmit = async () => {
if (!psbt || !depositOffer) { // Ensure PSBT and pre_deposit info exist
return;
}
setIsSubmiting(true);
try {
// Get the signed PSBT hex from the user's wallet
const psbtBase64 = psbt.toBase64();
const res = await signPsbt(psbtBase64); // Wallet signing function
const signedPsbtHex = res?.signedPsbtHex ?? "";
if (!signedPsbtHex) throw new Error("Signing Failed");
// Construct the IntentionSet
const intentionSet = {
tx_fee_in_sats: fee, // Calculated fee
initiator_address: paymentAddress, // User's address
intentions: [
{
action: "deposit",
exchange_id: EXCHANGE_ID, // Your Exchange Canister ID
input_coins: inputCoins, // User's BTC input
pool_utxo_spend: [], // Pool spends nothing in simple deposit
pool_utxo_receive: poolReceiveOutpoints, // Expected pool output
output_coins: [], // No other outputs in simple deposit
pool_address: pool.address,
action_params: "",
nonce: depositOffer.nonce, // Nonce from pre_deposit
},
],
};
// Call the Orchestrator's invoke method
const txid = await Orchestrator.invoke({
intention_set: intentionSet,
psbt_hex: signedPsbtHex,
});
// Handle success (e.g., update UI, track spent UTXOs)
addSpentUtxos(toSpendUtxos);
onSuccess(txid);
} catch (error: any) {
// Handle errors (e.g., user rejection, network issues)
if (error.code !== 4001) { // Ignore user wallet rejection
console.error(error);
toast(error.toString());
}
} finally {
setIsSubmiting(false);
}
};- The frontend sends the user-signed PSBT along with an
State Management: Handling Blockchain Events
Previously, we implemented the flow for a successful deposit. However, a robust REE exchange must also handle exceptional situations to ensure consistent operation. The main exceptions include Bitcoin blockchain reorganizations (reorgs) and scenarios where transactions broadcast by REE fail to get confirmed on the Bitcoin network.
We have already covered four of the six required REE interface methods. This chapter introduces the remaining two: new_block()
and rollback_tx()
. These methods are crucial for maintaining correct state in response to blockchain events.
It's important to note that the implementation logic for new_block()
and rollback_tx()
is often standard across most exchanges. We plan to potentially move this common logic into an SDK or framework in the future to simplify development. For now, we'll explain their purpose and provide reference implementations.
To enable state rollback in exceptional cases, we first need to add this code snippet at the end of the execute_tx()
method to record the pool associated with the transaction and whether it has been confirmed.
#[update(guard = "ensure_testnet4_orchestrator")]
pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse {
...
// Record the transaction as unconfirmed and track which pools it affects
crate::TX_RECORDS.with_borrow_mut(|m| {
ic_cdk::println!("new unconfirmed txid: {} in pool: {} ", txid, pool_address);
let mut record = m.get(&(txid.clone(), false)).unwrap_or_default();
if !record.pools.contains(&pool_address) {
record.pools.push(pool_address.clone());
}
m.insert((txid.clone(), false), record);
});
...
}
Handling New Blocks: new_block()
A note on REE transaction confirmation order: when a block contains multiple transactions, the Runes indexer calls the Orchestrator’s new_block_detected method, with the transactions in the parameters following the same order as they appear in the block.
The Orchestrator calls the new_block()
method on an exchange canister whenever it learns that a new Bitcoin block has been indexed.
Upon receiving this notification, the exchange typically performs the following actions:
- Check for Reorgs: Determine if the new block indicates a blockchain reorganization. If a reorg occurred, the confirmation count for recent transactions within the exchange needs recalculation.
- Update Confirmations: If there's no reorg, update the confirmation count for pending transactions based on the new block height.
- Finalize Transactions: If a transaction's confirmation count reaches the exchange's predefined security threshold (meaning it's considered highly unlikely to be reversed), the exchange can finalize its state. This often involves pruning older, now unnecessary state history related to that transaction to save storage costs.
Reference Implementation (new_block
):
#[update(guard = "ensure_testnet4_orchestrator")]
// Accepts notifications from the orchestrator about newly confirmed blocks
// Used to finalize transactions and handle blockchain reorganizations (reorgs)
// All exchanges implement this interface in the same way - will be moved to SDK in the future
// Only the orchestrator can call this function (ensured by the guard)
pub fn new_block(args: NewBlockArgs) -> NewBlockResponse {
// Check for blockchain reorganizations
match crate::reorg::detect_reorg(BitcoinNetwork::Testnet, args.clone()) {
Ok(_) => {} // No reorg or handled internally
Err(crate::reorg::Error::DuplicateBlock { height, hash }) => {
return Err(format!(
"Duplicate block detected at height {} with hash {}",
height, hash
));
}
Err(crate::reorg::Error::Unrecoverable) => {
// Critical error, manual intervention might be needed
return Err("Unrecoverable reorg detected".to_string());
}
Err(crate::reorg::Error::Recoverable { height, depth }) => {
// Handle the recoverable reorg (e.g., revert state)
crate::reorg::handle_reorg(height, depth);
}
}
let NewBlockArgs {
block_height,
block_hash: _,
block_timestamp: _,
confirmed_txids, // List of txids confirmed in this new block
} = args.clone();
// Store the new block information (e.g., for reorg detection)
crate::BLOCKS.with_borrow_mut(|m| {
m.insert(block_height, args);
ic_cdk::println!("new block {} inserted into blocks", block_height);
});
// Mark transactions included in this block as confirmed
for txid in confirmed_txids {
crate::TX_RECORDS.with_borrow_mut(|m| {
// Update record status from unconfirmed (false) to confirmed (true)
if let Some(record) = m.get(&(txid.clone(), false)) {
m.insert((txid.clone(), true), record.clone());
ic_cdk::println!("confirm txid: {} with pools: {:?}", txid, record.pools);
m.remove(&(txid.clone(), false)); // Optional: remove unconfirmed entry
}
});
}
// Calculate the height below which blocks are considered stable (finalized)
let confirmed_height =
block_height.saturating_sub(crate::reorg::get_max_recoverable_reorg_depth(BitcoinNetwork::Testnet)) + 1;
// Finalize transactions in blocks that are now considered stable
crate::BLOCKS.with_borrow(|m| {
m.iter()
.take_while(|(height, _)| *height <= confirmed_height)
.for_each(|(height, block_info)| {
ic_cdk::println!("finalizing txs in stable block: {}", height);
block_info.confirmed_txids.iter().for_each(|txid| {
crate::TX_RECORDS.with_borrow_mut(|tx_records| {
// Get the record for the confirmed transaction
if let Some(record) = tx_records.get(&(txid.clone(), true)) {
ic_cdk::println!(
"finalize txid: {} with pools: {:?}",
txid,
record.pools
);
// Call finalize on each affected pool to make the state permanent
record.pools.iter().for_each(|pool_address| {
crate::LENDING_POOLS.with_borrow_mut(|pools| {
if let Some(mut pool) = pools.get(pool_address) {
// The finalize method removes state before this txid
if let Err(e) = pool.finalize(txid.clone()) {
ic_cdk::println!("Error finalizing pool {}: {:?}", pool_address, e);
} else {
pools.insert(pool_address.clone(), pool); // Update pool state
}
} else {
ic_cdk::println!("Pool {} not found during finalize", pool_address);
}
});
});
// Optional: Remove the finalized tx record if no longer needed
// tx_records.remove(&(txid.clone(), true));
}
});
});
});
});
// Clean up information for blocks older than the stable height
crate::BLOCKS.with_borrow_mut(|m| {
let heights_to_remove: Vec<u32> = m
.iter()
.take_while(|(height, _)| *height <= confirmed_height)
.map(|(height, _)| height)
.collect();
for height in heights_to_remove {
ic_cdk::println!("removing finalized block info: {}", height);
m.remove(&height);
}
});
Ok(())
}
Handling Rejected Transactions: rollback_tx()
The Orchestrator calls rollback_tx()
when it detects that a previously broadcast transaction is unlikely to ever be confirmed (e.g., it was rejected by the Bitcoin network or replaced by another transaction).
Since the exchange pool state is typically managed as a chain of states linked by transactions, the exchange needs to undo the state changes caused by the rejected transaction and any subsequent transactions that depended on it.
Reference Implementation (rollback_tx
):
#[update(guard = "ensure_testnet4_orchestrator")]
// Accepts notifications from the orchestrator to roll back rejected transactions
// When a transaction is rejected, this function returns the pool to its state before that transaction
// Only the orchestrator can call this function (ensured by the guard)
pub fn rollback_tx(args: RollbackTxArgs) -> RollbackTxResponse {
crate::TX_RECORDS.with_borrow_mut(|m| {
// Find the record for the transaction to be rolled back
// It could be marked as unconfirmed (false) or maybe even confirmed (true) if a reorg happened
let record = m.get(&(args.txid.clone(), false))
.or_else(|| m.get(&(args.txid.clone(), true)))
.cloned(); // Clone the record data
if let Some(record) = record {
ic_cdk::println!(
"rollback txid: {} affecting pools: {:?}",
args.txid,
record.pools
);
// Roll back each affected pool to its state *before* this transaction occurred
record.pools.iter().for_each(|pool_address| {
crate::LENDING_POOLS.with_borrow_mut(|pools| {
if let Some(mut pool) = pools.get(pool_address) {
// The rollback method removes the state for txid and subsequent states
if let Err(e) = pool.rollback(args.txid) {
ic_cdk::println!("Error rolling back pool {}: {:?}", pool_address, e);
} else {
pools.insert(pool_address.clone(), pool); // Update pool state
}
} else {
ic_cdk::println!("Pool {} not found during rollback", pool_address);
}
});
});
// Remove the transaction record after rollback
m.remove(&(args.txid.clone(), false));
m.remove(&(args.txid.clone(), true));
} else {
ic_cdk::println!("Transaction record not found for rollback: {}", args.txid);
// Depending on requirements, might return an error or just log
}
});
Ok(())
}
Supporting Pool Methods (finalize
and rollback
)
To support the new_block
and rollback_tx
logic, the Pool
struct needs methods to manipulate its chain of PoolState
entries.
impl Pool {
// Rollback the pool state to before the specified transaction
// Removes the state created by txid and all subsequent states
pub(crate) fn rollback(&mut self, txid: Txid) -> Result<(), ExchangeError> {
let idx = self
.states
.iter()
.position(|state| state.id == Some(txid))
.ok_or(ExchangeError::InvalidState("rollback txid not found in pool state chain".to_string()))?;
// Truncate the states vector, removing the state at index `idx` and all subsequent states
self.states.truncate(idx);
Ok(())
}
// Finalize a transaction by making its state the new base state
// Removes all states *before* the specified transaction
pub(crate) fn finalize(&mut self, txid: Txid) -> Result<(), ExchangeError> {
let idx = self
.states
.iter()
.position(|state| state.id == Some(txid))
.ok_or(ExchangeError::InvalidState("finalize txid not found in pool state chain".to_string()))?;
// If the state to finalize is already the first one, nothing to do
if idx == 0 {
return Ok(());
}
// Efficiently remove states before index `idx`
self.states.rotate_left(idx);
self.states.truncate(self.states.len() - idx);
Ok(())
}
}
By implementing new_block
and rollback_tx
(along with the supporting Pool
methods), an REE exchange can maintain accurate and consistent state even when faced with common blockchain events like confirmations, reorgs, and transaction rejections.
Last updated on July 20, 2025