Skip to content

Commit 6cf0782

Browse files
authored
Improve Asset Scale conversions (#192)
* fix(settlement-client): do not convert scale The scale conversion will be performed on the engine. We must send our asset scale to the engine so that it knows how to handle the number we sent it. * feat(settlement): Implement asset scale conversion for u256 * feat(settlement): Normalize the amount properly when being notified by the engine * fix(eth-se): Scale the amount to settle for based on the body and engine scale If we have configured the engine with asset scale 18, and the connector sends a body with scale 9 and amount 1, we should settle for 1e9 * test(eth-se): adjust test for proper scale conversion * test(eth-xrp) Set ETHXRP exchange rate * fix(crate): Remove settlement_engine_asset_scale from account * improvement(settlement/engines): use BigUInt to handle big numbers * fix(settlement): Convert asset scale properly Previously the Convert trait implementation had an arithmetic error, which was forcing us to use provide it parameters in the opposite order. * feat(settlement/engine): Return None when idempotent data not found instead of Error * fix(settlement): Make asset scale conversions overflow-safe - If exchange rate normalization fails return a reject packet - In all other places we use BigUint so the conversion should never fail, but we still handle the error graciously - prefer shadwing of variable name to avoid using the wrong variable - clarify comment about SettlementEngineDetails * improvement(exchange-rate): make the scale conversion after the exchange rate is applied * test(exchange-rate): Add tests which verify that rate conversions fail correctly
1 parent 7e2ff63 commit 6cf0782

File tree

31 files changed

+737
-392
lines changed

31 files changed

+737
-392
lines changed

crates/interledger-api/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ pub struct AccountDetails {
7171
pub amount_per_minute_limit: Option<u64>,
7272
pub packets_per_minute_limit: Option<u32>,
7373
pub settlement_engine_url: Option<String>,
74-
pub settlement_engine_asset_scale: Option<u8>,
7574
}
7675

7776
pub struct NodeApi<S, I> {

crates/interledger-service-util/src/exchange_rates_service.rs

Lines changed: 208 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,217 @@ where
9090
.build()));
9191
};
9292

93-
let scaled_rate = rate.normalize_scale(ConvertDetails {
94-
from: request.to.asset_scale(),
95-
to: request.from.asset_scale(),
93+
// Can we overflow here?
94+
let outgoing_amount = (request.prepare.amount() as f64) * rate;
95+
let outgoing_amount = outgoing_amount.normalize_scale(ConvertDetails {
96+
from: request.from.asset_scale(),
97+
to: request.to.asset_scale(),
9698
});
97-
let outgoing_amount = (request.prepare.amount() as f64) * scaled_rate;
98-
request.prepare.set_amount(outgoing_amount as u64);
99-
trace!("Converted incoming amount of: {} {} (scale {}) from account {} to outgoing amount of: {} {} (scale {}) for account {}", request.original_amount, request.from.asset_code(), request.from.asset_scale(), request.from.id(), outgoing_amount, request.to.asset_code(), request.to.asset_scale(), request.to.id());
99+
100+
match outgoing_amount {
101+
Ok(outgoing_amount) => {
102+
// The conversion succeeded, but the produced f64
103+
// is larger than the maximum value for a u64.
104+
// When it gets cast to a u64, it will end up being 0.
105+
if outgoing_amount != 0.0 && outgoing_amount as u64 == 0 {
106+
return Box::new(err(RejectBuilder {
107+
code: ErrorCode::F08_AMOUNT_TOO_LARGE,
108+
message: format!(
109+
"Could not cast outgoing amount to u64 {}",
110+
outgoing_amount,
111+
)
112+
.as_bytes(),
113+
triggered_by: Some(&self.ilp_address),
114+
data: &[],
115+
}
116+
.build()));
117+
}
118+
request.prepare.set_amount(outgoing_amount as u64);
119+
trace!("Converted incoming amount of: {} {} (scale {}) from account {} to outgoing amount of: {} {} (scale {}) for account {}",
120+
request.original_amount, request.from.asset_code(), request.from.asset_scale(), request.from.id(),
121+
outgoing_amount, request.to.asset_code(), request.to.asset_scale(), request.to.id());
122+
}
123+
Err(_) => {
124+
// This branch gets executed when the `Convert` trait
125+
// returns an error. Happens due to float
126+
// multiplication overflow .
127+
// (float overflow in Rust produces +inf)
128+
return Box::new(err(RejectBuilder {
129+
code: ErrorCode::F08_AMOUNT_TOO_LARGE,
130+
message: format!(
131+
"Could not convert exchange rate from {}:{} to: {}:{}. Got incoming amount: {}",
132+
request.from.asset_code(),
133+
request.from.asset_scale(),
134+
request.to.asset_code(),
135+
request.to.asset_scale(),
136+
request.prepare.amount(),
137+
)
138+
.as_bytes(),
139+
triggered_by: Some(&self.ilp_address),
140+
data: &[],
141+
}
142+
.build()));
143+
}
144+
}
100145
}
101146

102147
Box::new(self.next.send_request(request))
103148
}
104149
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
use futures::{future::ok, Future};
155+
use interledger_ildcp::IldcpAccount;
156+
use interledger_packet::{Address, FulfillBuilder, PrepareBuilder};
157+
use interledger_service::{outgoing_service_fn, Account};
158+
use std::collections::HashMap;
159+
use std::str::FromStr;
160+
use std::{
161+
sync::{Arc, Mutex},
162+
time::SystemTime,
163+
};
164+
165+
#[test]
166+
fn exchange_rate_ok() {
167+
let ret = exchange_rate(100, 1, 1.0, 1, 2.0);
168+
assert_eq!(ret.1[0].prepare.amount(), 200);
169+
170+
let ret = exchange_rate(1_000_000, 1, 3.0, 1, 2.0);
171+
assert_eq!(ret.1[0].prepare.amount(), 666_666);
172+
}
173+
174+
#[test]
175+
fn exchange_conversion_error() {
176+
// rejects f64 that does not fit in u64
177+
let ret = exchange_rate(std::u64::MAX, 1, 1.0, 1, 2.0);
178+
let reject = ret.0.unwrap_err();
179+
assert_eq!(reject.code(), ErrorCode::F08_AMOUNT_TOO_LARGE);
180+
assert!(reject.message().starts_with(b"Could not cast"));
181+
182+
// `Convert` errored
183+
let ret = exchange_rate(std::u64::MAX, 1, 1.0, 255, std::f64::MAX);
184+
let reject = ret.0.unwrap_err();
185+
assert_eq!(reject.code(), ErrorCode::F08_AMOUNT_TOO_LARGE);
186+
assert!(reject.message().starts_with(b"Could not convert"));
187+
}
188+
189+
// Instantiates an exchange rate service and returns the fulfill/reject
190+
// packet and the outgoing request after performing an asset conversion
191+
fn exchange_rate(
192+
amount: u64,
193+
scale1: u8,
194+
rate1: f64,
195+
scale2: u8,
196+
rate2: f64,
197+
) -> (Result<Fulfill, Reject>, Vec<OutgoingRequest<TestAccount>>) {
198+
let requests = Arc::new(Mutex::new(Vec::new()));
199+
let requests_clone = requests.clone();
200+
let outgoing = outgoing_service_fn(move |request| {
201+
requests_clone.lock().unwrap().push(request);
202+
Box::new(ok(FulfillBuilder {
203+
fulfillment: &[0; 32],
204+
data: b"hello!",
205+
}
206+
.build()))
207+
});
208+
let mut service = test_service(rate1, rate2, outgoing);
209+
let result = service
210+
.send_request(OutgoingRequest {
211+
from: TestAccount::new("ABC".to_owned(), scale1),
212+
to: TestAccount::new("XYZ".to_owned(), scale2),
213+
original_amount: amount,
214+
prepare: PrepareBuilder {
215+
destination: Address::from_str("example.destination").unwrap(),
216+
amount,
217+
expires_at: SystemTime::now(),
218+
execution_condition: &[1; 32],
219+
data: b"hello",
220+
}
221+
.build(),
222+
})
223+
.wait();
224+
225+
let reqs = requests.lock().unwrap();
226+
(result, reqs.clone())
227+
}
228+
229+
#[derive(Debug, Clone)]
230+
struct TestAccount {
231+
ilp_address: Address,
232+
asset_code: String,
233+
asset_scale: u8,
234+
}
235+
impl TestAccount {
236+
fn new(asset_code: String, asset_scale: u8) -> Self {
237+
TestAccount {
238+
ilp_address: Address::from_str("example.alice").unwrap(),
239+
asset_code,
240+
asset_scale,
241+
}
242+
}
243+
}
244+
245+
impl Account for TestAccount {
246+
type AccountId = u64;
247+
248+
fn id(&self) -> u64 {
249+
0
250+
}
251+
}
252+
253+
impl IldcpAccount for TestAccount {
254+
fn asset_code(&self) -> &str {
255+
&self.asset_code
256+
}
257+
258+
fn asset_scale(&self) -> u8 {
259+
self.asset_scale
260+
}
261+
262+
fn client_address(&self) -> &Address {
263+
&self.ilp_address
264+
}
265+
}
266+
267+
#[derive(Debug, Clone)]
268+
struct TestStore {
269+
rates: HashMap<Vec<String>, (f64, f64)>,
270+
}
271+
272+
impl ExchangeRateStore for TestStore {
273+
fn get_exchange_rates(&self, asset_codes: &[&str]) -> Result<Vec<f64>, ()> {
274+
let mut ret = Vec::new();
275+
let key = vec![asset_codes[0].to_owned(), asset_codes[1].to_owned()];
276+
let v = self.rates.get(&key);
277+
if let Some(v) = v {
278+
ret.push(v.0);
279+
ret.push(v.1);
280+
} else {
281+
return Err(());
282+
}
283+
Ok(ret)
284+
}
285+
}
286+
287+
fn test_store(rate1: f64, rate2: f64) -> TestStore {
288+
let mut rates = HashMap::new();
289+
rates.insert(vec!["ABC".to_owned(), "XYZ".to_owned()], (rate1, rate2));
290+
TestStore { rates }
291+
}
292+
293+
fn test_service(
294+
rate1: f64,
295+
rate2: f64,
296+
handler: impl OutgoingService<TestAccount> + Clone + Send + Sync,
297+
) -> ExchangeRateService<
298+
TestStore,
299+
impl OutgoingService<TestAccount> + Clone + Send + Sync,
300+
TestAccount,
301+
> {
302+
let store = test_store(rate1, rate2);
303+
ExchangeRateService::new(Address::from_str("example.bob").unwrap(), store, handler)
304+
}
305+
306+
}

crates/interledger-settlement-engines/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ http = "0.1.17"
3434
clap = "2.32.0"
3535
clarity = { git = "https://github.com/gakonst/clarity" }
3636
sha3 = "0.8.2"
37+
num-bigint = "0.2.2"
3738

3839
[dev-dependencies]
3940
lazy_static = "1.3"
@@ -43,4 +44,4 @@ net2 = "0.2.33"
4344
os_type = "2.2.0"
4445
rand = "0.7.0"
4546
interledger = { path = "../interledger", version = "0.4.0" }
46-
interledger-packet = { path = "../interledger-packet", version = "0.2.1" }
47+
interledger-packet = { path = "../interledger-packet", version = "0.2.1" }

crates/interledger-settlement-engines/src/api.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use futures::{
77
use hyper::{Response, StatusCode};
88
use interledger_settlement::Quantity;
99
use interledger_settlement::{IdempotentData, IdempotentStore};
10-
use log::trace;
10+
use log::error;
1111
use ring::digest::{digest, SHA256};
1212
use tokio::executor::spawn;
1313
use tower_web::{net::ConnectionStream, ServiceBuilder};
@@ -77,19 +77,23 @@ impl_web! {
7777
&self,
7878
idempotency_key: String,
7979
input_hash: [u8; 32],
80-
) -> impl Future<Item = (StatusCode, Bytes), Error = String> {
80+
) -> impl Future<Item = Option<(StatusCode, Bytes)>, Error = String> {
8181
self.store
8282
.load_idempotent_data(idempotency_key.clone())
8383
.map_err(move |_| {
84-
let error_msg = "Couldn't load idempotent data".to_owned();
85-
trace!("{}", error_msg);
84+
let error_msg = format!("Couldn't load idempotent data for idempotency key {:?}", idempotency_key);
85+
error!("{}", error_msg);
8686
error_msg
8787
})
88-
.and_then(move |ret: IdempotentData| {
89-
if ret.2 == input_hash {
90-
Ok((ret.0, ret.1))
88+
.and_then(move |ret: Option<IdempotentData>| {
89+
if let Some(ret) = ret {
90+
if ret.2 == input_hash {
91+
Ok(Some((ret.0, ret.1)))
92+
} else {
93+
Ok(Some((StatusCode::from_u16(409).unwrap(), Bytes::from(&b"Provided idempotency key is tied to other input"[..]))))
94+
}
9195
} else {
92-
Ok((StatusCode::from_u16(409).unwrap(), Bytes::from(&b"Provided idempotency key is tied to other input"[..])))
96+
Ok(None)
9397
}
9498
})
9599
}
@@ -104,8 +108,8 @@ impl_web! {
104108
Either::A(
105109
self.check_idempotency(idempotency_key.clone(), input_hash)
106110
.map_err(|err| Response::builder().status(502).body(err).unwrap())
107-
.then(move |ret: Result<(StatusCode, Bytes), Response<String>>| {
108-
if let Ok(ret) = ret {
111+
.and_then(move |ret: Option<(StatusCode, Bytes)>| {
112+
if let Some(ret) = ret {
109113
let resp = Response::builder().status(ret.0).body(String::from_utf8_lossy(&ret.1).to_string()).unwrap();
110114
if ret.0.is_success() {
111115
return Either::A(Either::A(ok(resp)))

crates/interledger-settlement-engines/src/engines/ethereum_ledger/eth_engine.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use ethereum_tx_sign::web3::{
1616
use hyper::StatusCode;
1717
use interledger_store_redis::RedisStoreBuilder;
1818
use log::info;
19+
use num_bigint::BigUint;
1920
use redis::IntoConnectionInfo;
2021
use reqwest::r#async::{Client, Response as HttpResponse};
2122
use ring::{digest, hmac};
@@ -35,7 +36,7 @@ use uuid::Uuid;
3536

3637
use crate::stores::redis_ethereum_ledger::*;
3738
use crate::{ApiResponse, CreateAccount, SettlementEngine, SettlementEngineApi};
38-
use interledger_settlement::Quantity;
39+
use interledger_settlement::{Convert, ConvertDetails, Quantity};
3940

4041
const MAX_RETRIES: usize = 10;
4142
const ETH_CREATE_ACCOUNT_PREFIX: &[u8] = b"ilp-ethl-create-account-message";
@@ -480,7 +481,7 @@ where
480481
) -> impl Future<Item = (), Error = ()> {
481482
let mut url = self.connector_url.clone();
482483
let account_id_clone = account_id.clone();
483-
let asset_scale = self.asset_scale;
484+
let engine_scale = self.asset_scale;
484485
url.path_segments_mut()
485486
.expect("Invalid connector URL")
486487
.push("accounts")
@@ -493,7 +494,7 @@ where
493494
client
494495
.post(url.clone())
495496
.header("Idempotency-Key", tx_hash.to_string())
496-
.json(&json!({ "amount": amount.to_string(), "scale" : asset_scale }))
497+
.json(&json!({ "amount": amount.to_string(), "scale" : engine_scale }))
497498
.send()
498499
.map_err(move |err| {
499500
error!(
@@ -757,12 +758,37 @@ where
757758
body: Quantity,
758759
) -> Box<dyn Future<Item = ApiResponse, Error = ApiResponse> + Send> {
759760
let self_clone = self.clone();
761+
let engine_scale = self.asset_scale;
760762
Box::new(
761-
result(U256::from_dec_str(&body.amount).map_err(move |err| {
762-
let error_msg = format!("Error converting to U256 {:?}", err);
763+
result(BigUint::from_str(&body.amount).map_err(move |err| {
764+
let error_msg = format!("Error converting to BigUint {:?}", err);
763765
error!("{:?}", error_msg);
764766
(StatusCode::from_u16(400).unwrap(), error_msg)
765767
}))
768+
.and_then(move |amount_from_connector| {
769+
// If we receive a Quantity { amount: "1", scale: 9},
770+
// we must normalize it to our engine's scale
771+
let amount = amount_from_connector.normalize_scale(ConvertDetails {
772+
from: body.scale,
773+
to: engine_scale,
774+
});
775+
776+
result(amount)
777+
.map_err(move |err| {
778+
let error_msg = format!("Error scaling amount: {:?}", err);
779+
error!("{:?}", error_msg);
780+
(StatusCode::from_u16(400).unwrap(), error_msg)
781+
})
782+
.and_then(move |amount| {
783+
// Typecast from num_bigint::BigUInt because we're using
784+
// ethereum_types::U256 for all rust-web3 related functionality
785+
result(U256::from_dec_str(&amount.to_string()).map_err(move |err| {
786+
let error_msg = format!("Error converting to U256 {:?}", err);
787+
error!("{:?}", error_msg);
788+
(StatusCode::from_u16(400).unwrap(), error_msg)
789+
}))
790+
})
791+
})
766792
.and_then(move |amount| {
767793
self_clone
768794
.load_account(account_id)
@@ -929,7 +955,7 @@ mod tests {
929955

930956
let bob_mock = mockito::mock("POST", "/accounts/42/settlements")
931957
.match_body(mockito::Matcher::JsonString(
932-
"{\"amount\": \"100\", \"scale\": 18 }".to_string(),
958+
"{\"amount\": \"100000000000\", \"scale\": 18 }".to_string(),
933959
))
934960
.with_status(200)
935961
.with_body("OK".to_string())
@@ -953,7 +979,7 @@ mod tests {
953979
);
954980

955981
let ret =
956-
block_on(alice_engine.send_money(bob.id.to_string(), Quantity::new(100, 6))).unwrap();
982+
block_on(alice_engine.send_money(bob.id.to_string(), Quantity::new(100, 9))).unwrap();
957983
assert_eq!(ret.0.as_u16(), 200);
958984
assert_eq!(ret.1, "OK");
959985

0 commit comments

Comments
 (0)