depolymerization

wire gateway for Bitcoin/Ethereum
Log | Files | Refs | Submodules | README | LICENSE

commit 5be1c86dc740f8bd6eea1c25301929d698c5eacb
parent 4b9a168bc7e97e48f1d11175a51da426f1cfe178
Author: Antoine A <>
Date:   Mon, 13 Dec 2021 19:33:30 +0100

wire-gateway: handle transfer request idempotence

Diffstat:
Mscript/test_gateway.sh | 9+++++++++
Mtest.conf | 2+-
Mwire-gateway/db/schema.sql | 3++-
Mwire-gateway/src/api_common.rs | 6+++---
Mwire-gateway/src/api_wire.rs | 5++---
Mwire-gateway/src/error.rs | 17+++++++++++++++++
Mwire-gateway/src/main.rs | 48++++++++++++++++++++++++++++++++++++++++--------
7 files changed, 74 insertions(+), 16 deletions(-)

diff --git a/script/test_gateway.sh b/script/test_gateway.sh @@ -112,6 +112,15 @@ for endpoint in incoming outgoing; do echo "" done +echo "----- Transfer idempotence -----" +DATA="{\"request_uid\":\"0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00\",\"amount\":\"BTC:0.000034\",\"exchange_base_url\":\"$BASE_URL\",\"wtid\":\"0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00\",\"credit_account\":\"payto://bitcoin/$ADDRESS\"}" +echo -n "Same:" +test `curl -w %{http_code} -s -o /dev/null -H "Content-Type: application/json" -d $DATA ${BANK_ENDPOINT}transfer` -eq 200 && echo -n " OK" || echo -n " Failed" +test `curl -w %{http_code} -s -o /dev/null -H "Content-Type: application/json" -d $DATA ${BANK_ENDPOINT}transfer` -eq 200 && echo " OK" || echo " Failed" +echo -n "Collision:" +DATA="{\"request_uid\":\"0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00\",\"amount\":\"BTC:0.000042\",\"exchange_base_url\":\"$BASE_URL\",\"wtid\":\"0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00\",\"credit_account\":\"payto://bitcoin/$ADDRESS\"}" +test `curl -w %{http_code} -s -o /dev/null -H "Content-Type: application/json" -d $DATA ${BANK_ENDPOINT}transfer` -eq 409 && echo " OK" || echo " Failed" + echo "----- Security -----" # Generate big random file diff --git a/test.conf b/test.conf @@ -1,5 +1,5 @@ [main] -BASE_URL = test.com +BASE_URL = http://test.com DB_URL = postgres://localhost/postgres?user=postgres&password=password PORT = 8060 PAYTO = payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj diff --git a/wire-gateway/db/schema.sql b/wire-gateway/db/schema.sql @@ -26,5 +26,6 @@ CREATE TABLE tx_out ( credit_acc TEXT NOT NULL, exchange_url TEXT NOT NULL, status SMALLINT NOT NULL, - txid BYTEA UNIQUE + txid BYTEA UNIQUE, + request_uid BYTEA NOT NULL UNIQUE ); \ No newline at end of file diff --git a/wire-gateway/src/api_common.rs b/wire-gateway/src/api_common.rs @@ -26,7 +26,7 @@ pub struct ErrorDetail { } /// <https://docs.taler.net/core/api-common.html#tsref-type-Timestamp> -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Timestamp { Never, Time(SystemTime), @@ -262,11 +262,11 @@ fn test_amount() { pub enum ParseBase32Error { #[error("Invalid Crockford’s base32 format")] Format, - #[error("Invalid length: expected {0} got {1}")] + #[error("Invalid length: expected {0} bytes got {1}")] Length(usize, usize), } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Base32<const L: usize>([u8; L]); impl<const L: usize> From<[u8; L]> for Base32<L> { diff --git a/wire-gateway/src/api_wire.rs b/wire-gateway/src/api_wire.rs @@ -10,7 +10,7 @@ pub struct TransferResponse { } /// <https://docs.taler.net/core/api-wire.html#tsref-type-TransferRequest> -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct TransferRequest { pub request_uid: HashCode, pub amount: Amount, @@ -79,4 +79,4 @@ pub struct HistoryParams { pub start: Option<u64>, pub delta: i64, pub long_pool_ms: Option<u64>, -} -\ No newline at end of file +} diff --git a/wire-gateway/src/error.rs b/wire-gateway/src/error.rs @@ -63,6 +63,14 @@ impl ServerError { } } + pub fn status(status: StatusCode) -> Self { + Self::new( + status, + Content::None, + status.canonical_reason().unwrap_or("").to_string(), + ) + } + pub fn code(status: StatusCode, code: ErrorCode) -> Self { Self::detail( status, @@ -80,6 +88,15 @@ impl ServerError { } } +impl From<tokio_postgres::Error> for ServerError { + fn from(e: tokio_postgres::Error) -> Self { + ServerError::catch_code( + e, + StatusCode::BAD_GATEWAY, + ErrorCode::GENERIC_DB_FETCH_FAILED, + ) + } +} pub trait CatchResult<T: Sized> { fn unexpected(self) -> Result<T, ServerError>; fn catch_code(self, status: StatusCode, code: ErrorCode) -> Result<T, ServerError>; diff --git a/wire-gateway/src/main.rs b/wire-gateway/src/main.rs @@ -63,7 +63,7 @@ async fn main() { .map(|it| match it { Host::Tcp(it) => it.to_string(), #[cfg(target_os = "linux")] - Host::Unix(it) => it.to_str().unwrap().to_string() + Host::Unix(it) => it.to_str().unwrap().to_string(), }) .collect(), ); @@ -197,17 +197,49 @@ async fn router( ErrorCode::GENERIC_PARAMETER_MALFORMED, )); } - let timestamp = Timestamp::now(); let db = state.pool.get().await.catch_code( StatusCode::GATEWAY_TIMEOUT, ErrorCode::GENERIC_DB_FETCH_FAILED, )?; - let row = db.query_one("INSERT INTO tx_out (_date, amount, wtid, debit_acc, credit_acc, exchange_url, status) VALUES (now(), $1, $2, $3, $4, $5, $6) RETURNING id", &[ - &request.amount.to_string(), &request.wtid.as_ref(), &state.pay_to, &request.credit_account.to_string(), &request.exchange_base_url.to_string(), &0i16 - ]).await.catch_code( - StatusCode::BAD_GATEWAY, - ErrorCode::GENERIC_DB_FETCH_FAILED, - )?; + // Handle idempotence, check previous transaction with the same request_uid + let row = db.query_opt("SELECT amount, exchange_url, wtid, credit_acc, id, _date FROM tx_out WHERE request_uid = $1", &[&request.request_uid.as_ref()]) + .await?; + if let Some(row) = row { + let prev = TransferRequest { + request_uid: request.request_uid.clone(), + amount: Amount::from_str(row.get(0)).unwrap(), + exchange_base_url: Url::parse(row.get(1)).unwrap(), + wtid: { + let slice: &[u8] = row.get(2); + let array: [u8; 32] = slice.try_into().unwrap(); + ShortHashCode::from(array) + }, + credit_account: Url::parse(row.get(3)).unwrap(), + }; + if prev == request { + // Idempotence + return Ok(encode_body( + parts, + StatusCode::OK, + &TransferResponse { + timestamp: Timestamp::Time(row.get(5)), + row_id: { + let id: i32 = row.get(4); + SafeUint64::try_from(id as u64).unwrap() + }, + }, + ) + .await + .unexpected()?); + } else { + return Err(ServerError::status(StatusCode::CONFLICT)); + } + } + + let timestamp = Timestamp::now(); + let row = db.query_one("INSERT INTO tx_out (_date, amount, wtid, debit_acc, credit_acc, exchange_url, status, request_uid) VALUES (now(), $1, $2, $3, $4, $5, $6, $7) RETURNING id", &[ + &request.amount.to_string(), &request.wtid.as_ref(), &state.pay_to, &request.credit_account.to_string(), &request.exchange_base_url.to_string(), &0i16, &request.request_uid.as_ref() + ]).await?; encode_body( parts, StatusCode::OK,