Skip to content
Open
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,5 +222,36 @@ send JSON via POST method like

* Get note acceptance rules ( `http://127.0.0.1:8080/api/v1/acceptance/` )

* Initiate a reserve refund ( `http://127.0.0.1:8080/api/v1/reserves/refund` - POST method )

Initiating a refund writes the current block height (`R5`) and requested refund cap in nanoErgs (`R6`) into the reserve box without withdrawing collateral immediately. After the on-chain timelock (`14400` blocks, ~20 days) expires, the server's background refund worker automatically executes the refund withdrawal. If `amount` is omitted, it defaults to the maximum refundable balance (`reserve.value - 1000000` nanoErgs).

```json
{
"reserve_id": "0f44aa54140dbd5368b44358630d5ca4e38e6405f76bd987e18d7eae667915db",
"amount": 1000000000
}
```

* Cancel an active refund ( `http://127.0.0.1:8080/api/v1/reserves/refund/cancel` - POST method )

```json
{
"reserve_id": "0f44aa54140dbd5368b44358630d5ca4e38e6405f76bd987e18d7eae667915db"
}
```

* Complete an unlocked refund on demand ( `http://127.0.0.1:8080/api/v1/reserves/refund/complete` - POST method )

```json
{
"reserve_id": "0f44aa54140dbd5368b44358630d5ca4e38e6405f76bd987e18d7eae667915db"
}
```

* Get past and current refunds and their statuses ( `http://127.0.0.1:8080/api/v1/reserves/refunds` - GET method )

Supports optional query filters `?reserve_id=<reserveNftId>` and `?status=<pending|executing|completed|cancelled|failed>`, as well as fetching an individual refund by id via `http://127.0.0.1:8080/api/v1/reserves/refunds/:id`.

[Discord badge]: https://img.shields.io/discord/668903786361651200?logo=discord&style=social
[Discord link]: https://discord.gg/ergo-platform-668903786361651200
3 changes: 2 additions & 1 deletion crates/chaincash_app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::sync::Arc;
use chaincash_offchain::node::node_from_config;
use chaincash_predicate::predicates::Predicate;
use chaincash_server::Server;
use chaincash_services::{scanner::start_scanner, ServerState};
use chaincash_services::{refund_worker::start_refund_worker, scanner::start_scanner, ServerState};
use chaincash_store::{ChainCashStore, Update};
use config::{Environment, File};
use thiserror::Error;
Expand Down Expand Up @@ -89,6 +89,7 @@ impl ChainCashApp {

let state = Arc::new(ServerState::new(node, store, predicates));
start_scanner(state.clone()).await.unwrap();
start_refund_worker(state.clone());
Ok(Server::serve(listener, state).await?)
}
}
47 changes: 40 additions & 7 deletions crates/chaincash_offchain/src/boxes.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::note_history::{sign, NoteHistory, OwnershipEntry};
use crate::{
note_history::{sign, NoteHistory, OwnershipEntry},
transactions::reserves::calculate_refund_unlock_height,
};
use ergo_lib::{
ergo_chain_types::{
ec_point::{exponentiate, generator},
Expand All @@ -7,7 +10,9 @@ use ergo_lib::{
ergotree_interpreter::sigma_protocol::wscalar::Wscalar,
ergotree_ir::{
chain::{
ergo_box::{BoxId, ErgoBox, NonMandatoryRegisterId, RegisterValueError},
ergo_box::{
box_value::BoxValue, BoxId, ErgoBox, NonMandatoryRegisterId, RegisterValueError,
},
token::{Token, TokenAmount, TokenId},
},
mir::{avl_tree_data::AvlTreeData, constant::TryExtractInto},
Expand Down Expand Up @@ -167,10 +172,13 @@ impl Note {
}
}

#[derive(Serialize)]
#[derive(Clone, Debug, Serialize)]
pub struct ReserveBoxSpec {
pub owner: EcPoint,
pub refund_height: Option<i64>,
/// Block height when an active refund was initiated (`R5[Int]` in the reserve contract).
pub refund_height: Option<i32>,
/// Maximum nanoERG amount announced for refund (`R6[Long]` in the reserve contract).
pub refund_amount: Option<i64>,
pub identifier: TokenId,
#[serde(skip)]
inner: ErgoBox,
Expand All @@ -183,6 +191,18 @@ impl ReserveBoxSpec {
pub fn ergo_box(&self) -> &ErgoBox {
&self.inner
}
pub fn has_active_refund(&self) -> bool {
self.refund_height.is_some()
}
pub fn max_refundable_nanoergs(&self) -> u64 {
self.inner
.value
.as_u64()
.saturating_sub(*BoxValue::SAFE_USER_MIN.as_u64())
}
pub fn refund_unlock_height(&self) -> Option<u32> {
self.refund_height.map(calculate_refund_unlock_height)
}
}

impl TryFrom<&ErgoBox> for ReserveBoxSpec {
Expand All @@ -204,12 +224,25 @@ impl TryFrom<&ErgoBox> for ReserveBoxSpec {
})?;
let refund_height = value
.get_register(NonMandatoryRegisterId::R5.into())?
.map(|reg| {
if reg.tpe == SType::SInt {
Ok(reg.v.try_extract_into::<i32>().unwrap())
} else {
Err(Error::InvalidType {
field: "refund_height".to_owned(),
tpe: reg.tpe,
})
}
})
.transpose()?;
let refund_amount = value
.get_register(NonMandatoryRegisterId::R6.into())?
.map(|reg| {
if reg.tpe == SType::SLong {
Ok(reg.v.try_extract_into::<i64>().unwrap())
} else {
Err(Error::InvalidType {
field: "refund_height".to_owned(),
field: "refund_amount".to_owned(),
tpe: reg.tpe,
})
}
Expand All @@ -219,13 +252,13 @@ impl TryFrom<&ErgoBox> for ReserveBoxSpec {
.tokens
.as_ref()
.ok_or_else(|| Error::FieldNotSet("reserve box missing NFT"))?
.get(0)
.ok_or_else(|| Error::FieldNotSet("token at index 0 missing, no identifier nft"))?
.first()
.token_id;

Ok(Self {
owner,
refund_height,
refund_amount,
identifier,
inner: value.clone(),
})
Expand Down
12 changes: 12 additions & 0 deletions crates/chaincash_offchain/src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ pub fn create_wallet_box(public_key: EcPoint, amount: u64) -> ErgoBox {
}

pub fn create_reserve(public_key: EcPoint, amount: u64) -> ReserveBoxSpec {
create_reserve_with_pending_refund(public_key, amount, None)
}

pub fn create_reserve_with_pending_refund(
public_key: EcPoint,
amount: u64,
pending_refund: Option<(i32, i64)>,
) -> ReserveBoxSpec {
let mut box_candidate = ErgoBoxCandidateBuilder::new(
BoxValue::new(amount).unwrap(),
AddressEncoder::new(ergo_lib::ergotree_ir::chain::address::NetworkPrefix::Mainnet)
Expand All @@ -100,6 +108,10 @@ pub fn create_reserve(public_key: EcPoint, amount: u64) -> ReserveBoxSpec {
0,
);
box_candidate.set_register_value(NonMandatoryRegisterId::R4, public_key.into());
if let Some((init_height, refund_amount)) = pending_refund {
box_candidate.set_register_value(NonMandatoryRegisterId::R5, init_height.into());
box_candidate.set_register_value(NonMandatoryRegisterId::R6, refund_amount.into());
}
box_candidate.add_token(Token {
token_id: serde_json::from_str(
"\"161A3A5250655368566D597133743677397A24432646294A404D635166546A57\"",
Expand Down
24 changes: 24 additions & 0 deletions crates/chaincash_offchain/src/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ pub enum TransactionError {
#[error("Must top-up reserve with atleast 1 ERG, got {}", *.0 as f64 / 1_000_000_000.0)]
TopUpAmountError(u64),

#[error(
"Invalid refund amount {requested} nanoERG (reserve holds {reserve_value} nanoERG, max refundable {max_refundable} nanoERG)"
)]
InvalidRefundAmount {
requested: u64,
max_refundable: u64,
reserve_value: u64,
},

#[error("Reserve {0:?} already has an active refund pending")]
RefundAlreadyActive(TokenId),

#[error("Reserve {0:?} does not have an active refund")]
NoActiveRefund(TokenId),

#[error(
"Refund on reserve {reserve_id:?} is timelocked until height {unlock_height} (current height {current_height})"
)]
RefundTimelockActive {
reserve_id: TokenId,
unlock_height: u32,
current_height: u32,
},

#[error(
"Attempted to redeem from reserve {0:?}, note does not include ownership entry for this reserve"
)]
Expand Down
Loading