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

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

### Refunds

A refund is how a reserve owner takes collateral back out. It is timelocked: announcing a refund
starts a waiting period of 14400 blocks (about 20 days), during which the funds stay in the reserve
and note holders can still redeem against it. Once the period is over the server completes the
refund on its own, one check per block, so you do not have to come back for it.

* Announce a refund ( `http://127.0.0.1:8080/api/v1/reserves/refund` )

send JSON via POST method like

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

where `amount` is in nanoErgs. Nothing is withdrawn yet - the transaction only writes the
announcement into the reserve box. The result gives you the transaction id and the refund record,
including `unlock_height`, the height from which the funds can be taken out:

```json
{
"txId": "d2ccfce5c267d0f0fb51750d47ee966168611e40374e65df31aafba3abd954ef",
"refund": {
"id": 1,
"reserve_id": "0f44aa54140dbd5368b44358630d5ca4e38e6405f76bd987e18d7eae667915db",
"amount": 1000000,
"init_height": 1318639,
"unlock_height": 1333039,
"status": "initiated",
"init_tx_id": "d2ccfce5c267d0f0fb51750d47ee966168611e40374e65df31aafba3abd954ef"
}
}
```

A reserve can only have one refund pending at a time.

* Cancel a pending refund ( `http://127.0.0.1:8080/api/v1/reserves/refund/cancel` )

send JSON via POST method like

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

* Complete a pending refund now ( `http://127.0.0.1:8080/api/v1/reserves/refund/complete` )

same request body as cancel. The server does this by itself once the waiting period is over; this
is for when you would rather not wait for the next block to be scanned. It fails while the period
is still running - and so would the contract, were the check skipped.

The amount withdrawn is the announced one, capped at what the reserve can give up while remaining
a valid box: a redemption may have drained it in the meantime, and the announced amount is only
ever an upper bound as far as the contract is concerned. What actually left is reported as
`withdrawn_amount`.

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

newest first. Add `?reserve_id=$reserveNftId` to list the refunds of a single reserve.

[Discord badge]: https://img.shields.io/discord/668903786361651200?logo=discord&style=social
[Discord link]: https://discord.gg/ergo-platform-668903786361651200
5 changes: 4 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::{refunds::start_refund_watcher, scanner::start_scanner, ServerState};
use chaincash_store::{ChainCashStore, Update};
use config::{Environment, File};
use thiserror::Error;
Expand Down Expand Up @@ -89,6 +89,9 @@ impl ChainCashApp {

let state = Arc::new(ServerState::new(node, store, predicates));
start_scanner(state.clone()).await.unwrap();
// Refunds are timelocked: complete the ones whose waiting period elapsed while we were
// down, then keep settling them as they come due.
start_refund_watcher(state.clone());
Ok(Server::serve(listener, state).await?)
}
}
28 changes: 26 additions & 2 deletions crates/chaincash_offchain/src/boxes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ impl Note {
#[derive(Serialize)]
pub struct ReserveBoxSpec {
pub owner: EcPoint,
pub refund_height: Option<i64>,
/// Height at which a refund was initiated (R5), `None` when no refund is pending.
///
/// The reserve contract declares this register as `Int`, see the `init refund` action.
pub refund_height: Option<i32>,
/// Maximum amount (in nanoERG) the pending refund may withdraw (R6), `None` when no refund
/// is pending. Read by the contract as `SELF.R6[Long]` in the `complete refund` action.
pub refund_amount: Option<i64>,
pub identifier: TokenId,
#[serde(skip)]
inner: ErgoBox,
Expand All @@ -183,6 +189,10 @@ impl ReserveBoxSpec {
pub fn ergo_box(&self) -> &ErgoBox {
&self.inner
}
/// Whether a refund has been initiated on this reserve and not yet completed or cancelled.
pub fn refund_pending(&self) -> bool {
self.refund_height.is_some()
}
}

impl TryFrom<&ErgoBox> for ReserveBoxSpec {
Expand All @@ -204,12 +214,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 @@ -226,6 +249,7 @@ impl TryFrom<&ErgoBox> for ReserveBoxSpec {
Ok(Self {
owner,
refund_height,
refund_amount,
identifier,
inner: value.clone(),
})
Expand Down
14 changes: 14 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,16 @@ pub fn create_wallet_box(public_key: EcPoint, amount: u64) -> ErgoBox {
}

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

/// Reserve box with an optional pending refund, as written by the `init refund` action:
/// R5 holds the initiation height and R6 the announced amount.
pub fn create_reserve_with_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 +110,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((refund_height, refund_amount)) = pending_refund {
box_candidate.set_register_value(NonMandatoryRegisterId::R5, refund_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
16 changes: 16 additions & 0 deletions crates/chaincash_offchain/src/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ pub enum TransactionError {
#[error("Must top-up reserve with atleast 1 ERG, got {}", *.0 as f64 / 1_000_000_000.0)]
TopUpAmountError(u64),

#[error("Cannot refund {requested} nanoERG from a reserve holding {reserve_value} nanoERG")]
RefundAmountError { requested: u64, reserve_value: u64 },

#[error("Reserve {0:?} already has a refund pending, cancel it before starting another one")]
RefundAlreadyInitiated(TokenId),

#[error("Reserve {0:?} has no refund pending")]
RefundNotInitiated(TokenId),

#[error("Refund on reserve {reserve:?} is locked until height {unlock_height}, current height is {current_height}")]
RefundLocked {
reserve: 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