Skip to content

Commit 8fbd204

Browse files
committed
orders: support unwrapped denomination (#134)
## Motivation RAI-710: consumers need current order balances and IO ratios in unwrapped/tStock denomination while preserving wrapped default behavior. ## Solution - Add a shared denomination enum and conversion helpers. - Add `denomination=unwrapped` to order detail, orders by owner, and orders by token. - Fetch current wrap ratios through the shared wrap-ratio path and persist observations best-effort. - Include denomination in order-token cache keys. - Document default/current-rate unwrapped behavior in the orders docs. ## Checks - `nix develop -c rainix-rs-static` - `nix develop -c cargo test routes::orders` - `nix develop -c cargo test routes::order::get_order` - `nix develop -c cargo test` Closes RAI-710 Linear: https://linear.app/makeitrain/issue/RAI-710/add-denominationtstock-toggle-to-v1orders-endpoints
1 parent 3c6a925 commit 8fbd204

12 files changed

Lines changed: 621 additions & 85 deletions

File tree

docs/src/orders.md

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
Orders are persistent on-chain strategies that execute over time.
44

5-
The order endpoints return transaction calldata — the API does not execute transactions for you. You receive `to`, `data`, and `value` fields (plus any required token `approvals`) and submit those transactions on-chain yourself, the same pattern as the [Swap Flow](./swap-flow.md).
5+
The order endpoints return transaction calldata — the API does not execute
6+
transactions for you. You receive `to`, `data`, and `value` fields (plus any
7+
required token `approvals`) and submit those transactions on-chain yourself, the
8+
same pattern as the [Swap Flow](./swap-flow.md).
69

710
## Get DCA Order Calldata
811

912
```
1013
POST /v1/order/dca
1114
```
1215

13-
Returns calldata to deploy a DCA order that periodically buys a token at a set interval, with price bounds.
16+
Returns calldata to deploy a DCA order that periodically buys a token at a set
17+
interval, with price bounds.
1418

1519
### Request
1620

@@ -29,21 +33,22 @@ curl -X POST https://api.st0x.io/v1/order/dca \
2933
}'
3034
```
3135

32-
| Field | Type | Description |
33-
|-------|------|-------------|
34-
| `inputToken` | string | Token to spend |
35-
| `outputToken` | string | Token to receive |
36-
| `budgetAmount` | string | Total budget in human-readable units (e.g. `"10000"` for 10,000 USDC) |
37-
| `period` | number | Time between executions |
38-
| `periodUnit` | string | `"days"`, `"hours"`, or `"minutes"` |
39-
| `startIo` | string | Starting IO ratio |
40-
| `floorIo` | string | Minimum acceptable IO ratio |
41-
| `inputVaultId` | string (optional) | Existing vault ID for input token |
42-
| `outputVaultId` | string (optional) | Existing vault ID for output token |
36+
| Field | Type | Description |
37+
| --------------- | ----------------- | --------------------------------------------------------------------- |
38+
| `inputToken` | string | Token to spend |
39+
| `outputToken` | string | Token to receive |
40+
| `budgetAmount` | string | Total budget in human-readable units (e.g. `"10000"` for 10,000 USDC) |
41+
| `period` | number | Time between executions |
42+
| `periodUnit` | string | `"days"`, `"hours"`, or `"minutes"` |
43+
| `startIo` | string | Starting IO ratio |
44+
| `floorIo` | string | Minimum acceptable IO ratio |
45+
| `inputVaultId` | string (optional) | Existing vault ID for input token |
46+
| `outputVaultId` | string (optional) | Existing vault ID for output token |
4347

4448
### Response
4549

46-
The response always includes all fields. If approvals are needed, `data` is empty and `approvals` contains the required transactions:
50+
The response always includes all fields. If approvals are needed, `data` is
51+
empty and `approvals` contains the required transactions:
4752

4853
```json
4954
{
@@ -62,7 +67,9 @@ The response always includes all fields. If approvals are needed, `data` is empt
6267
}
6368
```
6469

65-
Send each approval transaction on-chain, then call the endpoint again. Once approvals are in place, `approvals` is empty and `data` contains the deployment calldata:
70+
Send each approval transaction on-chain, then call the endpoint again. Once
71+
approvals are in place, `approvals` is empty and `data` contains the deployment
72+
calldata:
6673

6774
```json
6875
{
@@ -88,6 +95,14 @@ curl https://api.st0x.io/v1/order/0xabc123... \
8895
-H "Authorization: Basic <credentials>"
8996
```
9097

98+
| Parameter | Type | Default | Description |
99+
| -------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------- |
100+
| `denomination` | `wrapped` or `unwrapped` | `wrapped` | Return wrapped token amounts as-is, or normalize wrapped token balances, trade amounts, and IO ratios to unwrapped values |
101+
102+
When `denomination=unwrapped`, order fields are normalized using the current
103+
wrapped exchange rate. Omit the parameter to keep the default wrapped-token
104+
response.
105+
91106
### Response
92107

93108
```json
@@ -143,10 +158,19 @@ curl "https://api.st0x.io/v1/orders/0xOwnerAddress?page=1&pageSize=10" \
143158
-H "Authorization: Basic <credentials>"
144159
```
145160

146-
| Parameter | Type | Default | Description |
147-
|-----------|------|---------|-------------|
148-
| `page` | number | 1 | Page number |
149-
| `pageSize` | number | 20 | Results per page |
161+
| Parameter | Type | Default | Description |
162+
| -------------- | ------------------------ | --------- | --------------------------------------------------------------------------------------------------------- |
163+
| `page` | number | 1 | Page number |
164+
| `pageSize` | number | 20 | Results per page |
165+
| `denomination` | `wrapped` or `unwrapped` | `wrapped` | Return wrapped token amounts as-is, or normalize wrapped token balances and IO ratios to unwrapped values |
166+
167+
Use `denomination=unwrapped` to view order balances and IO ratios normalized to
168+
the current unwrapped asset value:
169+
170+
```bash
171+
curl "https://api.st0x.io/v1/orders/0xOwnerAddress?page=1&pageSize=10&denomination=unwrapped" \
172+
-H "Authorization: Basic <credentials>"
173+
```
150174

151175
### Response
152176

@@ -253,4 +277,5 @@ curl -X POST https://api.st0x.io/v1/order/cancel \
253277
}
254278
```
255279

256-
Execute each transaction in the `transactions` array sequentially. The `summary` shows what tokens you will receive back.
280+
Execute each transaction in the `transactions` array sequentially. The `summary`
281+
shows what tokens you will receive back.

src/denomination.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use crate::error::ApiError;
2+
use crate::wrap_ratio::WrapRatioValue;
3+
use alloy::primitives::Address;
4+
use rain_math_float::Float;
5+
use std::collections::HashMap;
6+
use std::ops::{Div, Mul};
7+
8+
pub(crate) type WrapRatioMap = HashMap<Address, WrapRatioValue>;
9+
10+
pub(crate) fn convert_wrapped_amount_for_token(
11+
amount: String,
12+
token: Address,
13+
ratios: &WrapRatioMap,
14+
) -> Result<String, ApiError> {
15+
let Some(ratio) = ratios.get(&token) else {
16+
return Ok(amount);
17+
};
18+
19+
let amount = parse_float(amount, "amount")?;
20+
let converted = amount.mul(parse_ratio(ratio)?).map_err(|e| {
21+
tracing::error!(error = %e, "failed to convert wrapped amount");
22+
ApiError::Internal("failed to convert wrapped amount".into())
23+
})?;
24+
format_float(converted, "amount")
25+
}
26+
27+
pub(crate) fn convert_wrapped_io_ratio(
28+
io_ratio: String,
29+
input_token: Address,
30+
output_token: Address,
31+
ratios: &WrapRatioMap,
32+
) -> Result<String, ApiError> {
33+
if io_ratio == "-" {
34+
return Ok(io_ratio);
35+
}
36+
37+
let io_ratio = parse_float(io_ratio, "io_ratio")?;
38+
let input_assets_per_share = ratio_for_token(input_token, ratios)?;
39+
let output_assets_per_share = ratio_for_token(output_token, ratios)?;
40+
41+
let converted = io_ratio
42+
.mul(input_assets_per_share)
43+
.and_then(|ratio| ratio.div(output_assets_per_share))
44+
.map_err(|e| {
45+
tracing::error!(error = %e, "failed to convert wrapped IO ratio");
46+
ApiError::Internal("failed to convert wrapped IO ratio".into())
47+
})?;
48+
49+
format_float(converted, "io_ratio")
50+
}
51+
52+
fn ratio_for_token(token: Address, ratios: &WrapRatioMap) -> Result<Float, ApiError> {
53+
match ratios.get(&token) {
54+
Some(ratio) => parse_ratio(ratio),
55+
None => Float::parse("1".to_string()).map_err(|e| {
56+
tracing::error!(error = %e, token = %token, "failed to create identity wrap ratio");
57+
ApiError::Internal("failed to convert denomination".into())
58+
}),
59+
}
60+
}
61+
62+
fn parse_ratio(ratio: &WrapRatioValue) -> Result<Float, ApiError> {
63+
Float::parse(ratio.assets_per_share.clone()).map_err(|e| {
64+
tracing::error!(
65+
error = %e,
66+
share_address = %ratio.share_address,
67+
assets_per_share = %ratio.assets_per_share,
68+
"failed to parse wrapped token ratio"
69+
);
70+
ApiError::Internal("failed to read wrapped token ratio".into())
71+
})
72+
}
73+
74+
fn parse_float(value: String, label: &str) -> Result<Float, ApiError> {
75+
Float::parse(value).map_err(|e| {
76+
tracing::error!(error = %e, label, "failed to parse denomination value");
77+
ApiError::Internal(format!("failed to parse {label}"))
78+
})
79+
}
80+
81+
fn format_float(value: Float, label: &str) -> Result<String, ApiError> {
82+
value.format().map_err(|e| {
83+
tracing::error!(error = %e, label, "failed to format denomination value");
84+
ApiError::Internal(format!("failed to format {label}"))
85+
})
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::*;
91+
use alloy::primitives::address;
92+
93+
const WT_MSTR: Address = address!("ff05e1bd696900dc6a52ca35ca61bb1024eda8e2");
94+
const WT_COIN: Address = address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
95+
const USDC: Address = address!("833589fcd6edb6e08f4c7c32d4f71b54bda02913");
96+
97+
fn ratio(share_address: Address, assets_per_share: &str) -> WrapRatioValue {
98+
WrapRatioValue {
99+
share_address,
100+
assets_per_share: assets_per_share.to_string(),
101+
}
102+
}
103+
104+
#[test]
105+
fn converts_amount_for_wrapped_token() {
106+
let ratios = HashMap::from([(WT_MSTR, ratio(WT_MSTR, "2"))]);
107+
108+
let result = convert_wrapped_amount_for_token("1.5".to_string(), WT_MSTR, &ratios)
109+
.expect("convert amount");
110+
111+
assert_eq!(result, "3");
112+
}
113+
114+
#[test]
115+
fn leaves_amount_for_unwrapped_token() {
116+
let ratios = HashMap::from([(WT_MSTR, ratio(WT_MSTR, "2"))]);
117+
118+
let result = convert_wrapped_amount_for_token("1.5".to_string(), USDC, &ratios)
119+
.expect("convert amount");
120+
121+
assert_eq!(result, "1.5");
122+
}
123+
124+
#[test]
125+
fn converts_io_ratio_for_wrapped_sides() {
126+
let ratios = HashMap::from([
127+
(WT_MSTR, ratio(WT_MSTR, "2")),
128+
(WT_COIN, ratio(WT_COIN, "4")),
129+
]);
130+
131+
let result = convert_wrapped_io_ratio("10".to_string(), WT_MSTR, WT_COIN, &ratios)
132+
.expect("convert ratio");
133+
134+
assert_eq!(result, "5");
135+
}
136+
137+
#[test]
138+
fn preserves_dash_io_ratio() {
139+
let ratios = HashMap::from([(WT_MSTR, ratio(WT_MSTR, "2"))]);
140+
141+
let result = convert_wrapped_io_ratio("-".to_string(), WT_MSTR, USDC, &ratios)
142+
.expect("convert ratio");
143+
144+
assert_eq!(result, "-");
145+
}
146+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod catchers;
88
mod cli;
99
mod config;
1010
mod db;
11+
mod denomination;
1112
mod erc4626;
1213
mod error;
1314
mod fairings;

src/routes/order/cancel.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub async fn post_order_cancel(
4343
let ds = RaindexOrderDataSource {
4444
client: raindex.client(),
4545
caches: &app_state.response_caches,
46+
pool: None,
4647
};
4748
let response = process_cancel_order(&ds, hash).await?;
4849
Ok(Json(response))

0 commit comments

Comments
 (0)