taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit d1f3df0bc7123e804dca57d528f979c919793619
parent 4836612545adef76c0329c47e620f7ab34a2032d
Author: Antoine A <>
Date:   Tue, 24 Mar 2026 14:08:37 +0100

WIP

Diffstat:
Mcommon/taler-api/db/taler-api-0001.sql | 54+++++++++++++++++++++++++++++++++++-------------------
Dcommon/taler-api/db/taler-api-0002.sql | 21---------------------
Mcommon/taler-api/db/taler-api-procedures.sql | 51+++++++++++++++++++++++++++++++--------------------
Mcommon/taler-api/src/db.rs | 2+-
Mcommon/taler-api/src/subject.rs | 498+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcommon/taler-api/tests/common/db.rs | 45++++++++++++++++++++++++---------------------
Mcommon/taler-common/src/types/payto.rs | 7+++++++
Mcommon/taler-test-utils/src/routine.rs | 4++--
Mtaler-cyclos/src/db.rs | 4+---
Mtaler-magnet-bank/src/db.rs | 4+---
10 files changed, 401 insertions(+), 289 deletions(-)

diff --git a/common/taler-api/db/taler-api-0001.sql b/common/taler-api/db/taler-api-0001.sql @@ -21,28 +21,39 @@ SET search_path TO taler_api; CREATE TYPE taler_amount AS (val INT8, frac INT4); COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; -CREATE TYPE incoming_type AS ENUM - ('reserve' ,'kyc', 'wad'); -COMMENT ON TYPE incoming_type IS 'Types of incoming talerable transactions'; CREATE TABLE tx_in ( tx_in_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, amount taler_amount NOT NULL, subject TEXT NOT NULL, debit_payto TEXT NOT NULL, - type incoming_type NOT NULL, - metadata BYTEA NOT NULL, - origin_exchange_url TEXT, - created_at INT8 NOT NULL, - CONSTRAINT polymorphism CHECK( - CASE type - WHEN 'wad' THEN LENGTH(metadata)=24 AND origin_exchange_url IS NOT NULL - ELSE LENGTH(metadata)=32 AND origin_exchange_url IS NULL - END - ) + created_at INT8 NOT NULL ); -CREATE UNIQUE INDEX tx_in_unique_reserve_pub ON tx_in (metadata) WHERE type = 'reserve'; COMMENT ON TABLE tx_in IS 'Incoming transactions'; +CREATE TABLE tx_out ( + tx_out_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + amount taler_amount NOT NULL, + subject TEXT NOT NULL, + credit_payto TEXT NOT NULL, + created_at INT8 NOT NULL +); +COMMENT ON TABLE tx_out IS 'Outgoing transactions'; + +CREATE TYPE incoming_type AS ENUM + ('reserve' ,'kyc', 'map'); +COMMENT ON TYPE incoming_type IS 'Types of incoming talerable transactions'; + +CREATE TABLE taler_in ( + tx_in_id INT8 PRIMARY KEY REFERENCES tx_in(tx_in_id) ON DELETE CASCADE, + type incoming_type NOT NULL, + account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), + authorization_pub BYTEA CHECK (LENGTH(authorization_pub)=32), + authorization_sig BYTEA CHECK (LENGTH(authorization_sig)=64) +); +COMMENT ON TABLE tx_in IS 'Incoming talerable transactions'; + +CREATE UNIQUE INDEX taler_in_unique_reserve_pub ON taler_in (account_pub) WHERE type = 'reserve'; + CREATE TYPE transfer_status AS ENUM ( 'pending' ,'transient_failure' @@ -53,14 +64,19 @@ COMMENT ON TYPE transfer_status IS 'Status of a Wire Gateway transfer'; CREATE TABLE transfer ( transfer_id INT8 PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + tx_out_id INT8 NOT NULL UNIQUE REFERENCES tx_out(tx_out_id) ON DELETE CASCADE, request_uid BYTEA UNIQUE NOT NULL CHECK (LENGTH(request_uid)=64), - amount taler_amount NOT NULL, - subject TEXT NOT NULL, - credit_payto TEXT NOT NULL, wtid BYTEA UNIQUE NOT NULL CHECK (LENGTH(wtid)=32), exchange_base_url TEXT NOT NULL, + metadata TEXT, status transfer_status NOT NULL, - status_msg TEXT, - created_at INT8 NOT NULL + status_msg TEXT ); COMMENT ON TABLE transfer IS 'Wire Gateway transfers'; + +CREATE TABLE bounced( + tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE, + tx_out_id INT8 NOT NULL UNIQUE REFERENCES tx_out(tx_out_id) ON DELETE CASCADE, + reason TEXT NOT NULL +); +COMMENT ON TABLE tx_in IS 'Bounced transaction'; diff --git a/common/taler-api/db/taler-api-0002.sql b/common/taler-api/db/taler-api-0002.sql @@ -1,21 +0,0 @@ --- --- This file is part of TALER --- Copyright (C) 2026 Taler Systems SA --- --- TALER is free software; you can redistribute it and/or modify it under the --- terms of the GNU General Public License as published by the Free Software --- Foundation; either version 3, or (at your option) any later version. --- --- TALER is distributed in the hope that it will be useful, but WITHOUT ANY --- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR --- A PARTICULAR PURPOSE. See the GNU General Public License for more details. --- --- You should have received a copy of the GNU General Public License along with --- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - -SELECT _v.register_patch('taler-api-0002', NULL, NULL); - -SET search_path TO taler_api; - --- Add outgoing transactions metadata field -ALTER TABLE transfer ADD COLUMN metadata TEXT; diff --git a/common/taler-api/db/taler-api-procedures.sql b/common/taler-api/db/taler-api-procedures.sql @@ -55,6 +55,8 @@ CREATE FUNCTION taler_transfer( OUT out_created_at INT8 ) LANGUAGE plpgsql AS $$ +DECLARE + local_tx_id INT8; BEGIN -- Check for idempotence and conflict SELECT (amount != in_amount @@ -65,6 +67,7 @@ SELECT (amount != in_amount ,transfer_id, created_at INTO out_request_uid_reuse, out_transfer_row_id, out_created_at FROM transfer + JOIN tx_out USING (tx_out_id) WHERE request_uid = in_request_uid; IF FOUND THEN RETURN; @@ -76,28 +79,33 @@ IF out_wtid_reuse THEN END IF; out_created_at=in_now; -- Register exchange -INSERT INTO transfer ( +INSERT INTO tx_out ( amount, - exchange_base_url, - metadata, subject, credit_payto, + created_at +) VALUES ( + in_amount, + in_subject, + in_credit_payto, + in_now +) RETURNING tx_out_id INTO local_tx_id; +INSERT INTO transfer ( + tx_out_id, + exchange_base_url, + metadata, request_uid, wtid, status, - status_msg, - created_at + status_msg ) VALUES ( - in_amount, + local_tx_id, in_exchange_base_url, in_metadata, - in_subject, - in_credit_payto, in_request_uid, in_wtid, 'success', - NULL, - in_now + NULL ) RETURNING transfer_id INTO out_transfer_row_id; -- Notify new transaction PERFORM pg_notify('outgoing_tx', out_transfer_row_id || ''); @@ -109,7 +117,7 @@ CREATE FUNCTION add_incoming( IN in_subject TEXT, IN in_debit_payto TEXT, IN in_type incoming_type, - IN in_metadata BYTEA, + IN in_account_pub BYTEA, IN in_now INT8, -- Error status OUT out_reserve_pub_reuse BOOLEAN, @@ -120,7 +128,7 @@ CREATE FUNCTION add_incoming( LANGUAGE plpgsql AS $$ BEGIN -- Check conflict -SELECT in_type = 'reserve'::incoming_type AND EXISTS(SELECT FROM tx_in WHERE metadata = in_metadata AND type = 'reserve') +SELECT in_type = 'reserve'::incoming_type AND EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve') INTO out_reserve_pub_reuse; IF out_reserve_pub_reuse THEN RETURN; @@ -131,19 +139,22 @@ INSERT INTO tx_in ( amount, debit_payto, created_at, - subject, - type, - metadata, - origin_exchange_url + subject ) VALUES ( in_amount, in_debit_payto, in_now, - in_subject, - in_type, - in_metadata, - NULL + in_subject ) RETURNING tx_in_id INTO out_tx_row_id; +INSERT INTO taler_in ( + tx_in_id, + type, + account_pub +) VALUES ( + out_tx_row_id, + in_type, + in_account_pub +); -- Notify new incoming transaction PERFORM pg_notify('incoming_tx', out_tx_row_id || ''); END $$; diff --git a/common/taler-api/src/db.rs b/common/taler-api/src/db.rs @@ -46,7 +46,7 @@ pub type PgQueryBuilder<'b> = QueryBuilder<'b, Postgres>; pub enum IncomingType { reserve, kyc, - wad, + map, } /* ------ Serialization ----- */ diff --git a/common/taler-api/src/subject.rs b/common/taler-api/src/subject.rs @@ -19,6 +19,7 @@ use std::{ str::FromStr, }; +use aws_lc_rs::digest::{SHA256, digest}; use compact_str::CompactString; use taler_common::{ api_common::{EddsaPublicKey, ShortHashCode}, @@ -33,6 +34,8 @@ use crate::db::IncomingType; pub enum IncomingSubject { Reserve(EddsaPublicKey), Kyc(EddsaPublicKey), + Map(EddsaPublicKey), + AdminBalanceAdjust, } impl IncomingSubject { @@ -40,12 +43,17 @@ impl IncomingSubject { match self { IncomingSubject::Reserve(_) => IncomingType::reserve, IncomingSubject::Kyc(_) => IncomingType::kyc, + IncomingSubject::Map(_) => IncomingType::map, + IncomingSubject::AdminBalanceAdjust => panic!("Admin balance adjust"), } } pub fn key(&self) -> &[u8] { match self { - IncomingSubject::Kyc(key) | IncomingSubject::Reserve(key) => key.as_ref(), + IncomingSubject::Kyc(key) + | IncomingSubject::Reserve(key) + | IncomingSubject::Map(key) => key.as_ref(), + IncomingSubject::AdminBalanceAdjust => panic!("Admin balance adjust"), } } } @@ -162,7 +170,7 @@ pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> String { match ty { IncomingType::reserve => format!("{key}"), IncomingType::kyc => format!("KYC:{key}"), - IncomingType::wad => unreachable!(), + IncomingType::map => format!("MAP:{key}"), } } @@ -182,22 +190,31 @@ pub fn parse_incoming_unstructured( assert!(subject.len() <= u16::MAX as usize); const KEY_SIZE: usize = 52; - const KYC_SIZE: usize = KEY_SIZE + 3; + const PREFIXED_SIZE: usize = KEY_SIZE + 3; + const ADMIN_BALANCE_ADJUST: &str = "ADMINBALANCEADJUST"; /** Parse an incoming subject */ #[inline] fn parse_single(str: &str) -> Option<Candidate> { + if str == ADMIN_BALANCE_ADJUST { + return Some(Candidate { + subject: IncomingSubject::AdminBalanceAdjust, + quality: Base32Quality::UpperStandard, + }); + } // Check key type - let (is_kyc, raw) = match str.len() { - KEY_SIZE => (false, str), - KYC_SIZE => { + let (ty, raw) = match str.len() { + KEY_SIZE => (IncomingType::reserve, str), + PREFIXED_SIZE => { if let Some(key) = str.strip_prefix("KYC") { - (true, key) + (IncomingType::kyc, key) + } else if let Some(key) = str.strip_prefix("MAP") { + (IncomingType::map, key) } else { return None; } } - _ => unreachable!(), + _ => return None, }; // Check key validity @@ -205,10 +222,10 @@ pub fn parse_incoming_unstructured( let quality = Base32Quality::measure(raw); Some(Candidate { - subject: if is_kyc { - IncomingSubject::Kyc(key) - } else { - IncomingSubject::Reserve(key) + subject: match ty { + IncomingType::reserve => IncomingSubject::Reserve(key), + IncomingType::kyc => IncomingSubject::Kyc(key), + IncomingType::map => IncomingSubject::Map(key), }, quality, }) @@ -217,7 +234,7 @@ pub fn parse_incoming_unstructured( // Find and concatenate valid parts of a keys let (parts, concatenated) = { let mut parts = Vec::with_capacity(4); - let mut concatenated = String::with_capacity(subject.len().min(KYC_SIZE + 10)); + let mut concatenated = String::with_capacity(subject.len().min(PREFIXED_SIZE + 10)); parts.push(0u16); for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) { // SAFETY: part are all valid ASCII alphanumeric @@ -235,9 +252,9 @@ pub fn parse_incoming_unstructured( for &end in parts[i..].iter().skip(1) { let len = (end - start) as usize; // Until they are to long to be a key - if len > KYC_SIZE { + if len > PREFIXED_SIZE { break; - } else if len != KEY_SIZE && len != KYC_SIZE { + } else if len != KEY_SIZE && len != PREFIXED_SIZE && len != ADMIN_BALANCE_ADJUST.len() { continue; } @@ -251,7 +268,7 @@ pub fn parse_incoming_unstructured( if other.quality > best.quality // We prefer high quality keys || matches!( // We prefer prefixed keys over reserve keys (&best.subject.ty(), &other.subject.ty()), - (IncomingType::reserve, IncomingType::kyc | IncomingType::wad) + (IncomingType::reserve, IncomingType::kyc | IncomingType::map) ) { *best = other @@ -259,7 +276,7 @@ pub fn parse_incoming_unstructured( && best.quality == other.quality // Of same quality && !matches!( // And prefixing is different (&best.subject.ty(), &other.subject.ty()), - (IncomingType::kyc | IncomingType::wad, IncomingType::reserve) + (IncomingType::kyc | IncomingType::map, IncomingType::reserve) ) { return Err(IncomingSubjectErr::Ambiguous); @@ -274,209 +291,292 @@ pub fn parse_incoming_unstructured( Ok(best.map(|it| it.subject)) } -#[test] -/** Test parsing logic */ -fn parse() { - let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; - let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; - - // Common checks - for ty in [IncomingType::reserve, IncomingType::kyc] { - let prefix = match ty { - IncomingType::reserve => "", - IncomingType::kyc => "KYC", - IncomingType::wad => unreachable!(), - }; - let standard = &format!("{prefix}{key}"); - let (standard_l, standard_r) = standard.split_at(standard.len() / 2); - let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); - let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); - let other_standard = &format!("{prefix}{other}"); - let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); - - let result = Ok(Some(match ty { - IncomingType::reserve => { - IncomingSubject::Reserve(EddsaPublicKey::from_str(key).unwrap()) +// Modulo 10 Recursive +fn mod10_recursive(bytes: &[u8]) -> u8 { + const LOOKUP_TABLE: [u8; 10] = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5]; + // Modulo 10 Recursive calculation + let mut carry = 0u8; + for &b in bytes { + // ASCII '0'-'9' is 0x30-0x39. Subtracting b'0' (48) gives the integer. + let digit = b - b'0'; + carry = LOOKUP_TABLE[((carry + digit) % 10) as usize]; + } + carry +} + +/// Encode a public key as a QR-Bill reference +pub fn subject_fmt_qr_bill(key_bytes: &[u8]) -> String { + // High-Entropy Hash (SHA-256) to ensure even distribution + let hash = digest(&SHA256, key_bytes); + + // Compute hash % 10^26 + let hash_mod = hash.as_ref().chunks(3).fold(0u128, |rem, chunk| { + chunk.iter().fold(rem, |r, &b| r * 256 + b as u128) % 10u128.pow(26) + }); + + // Format to 26 digits with leading zeros + let reference_base = format!("{:0>26}", hash_mod); + + // Modulo 10 Recursive calculation + let carry = mod10_recursive(reference_base.as_bytes()); + let checksum = (10 - carry) % 10; + + // Combine base (26) + checksum (1) = 27 characters + format!("{}{}", reference_base, checksum) +} + +/// Check if a string is a valid QR-Bill reference +pub fn subject_is_qr_bill(reference: &str) -> bool { + // Quick length and numeric check + if reference.len() != 27 || !reference.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + // If the check digit is correct, the final carry will be 0 + mod10_recursive(reference.as_bytes()) == 0 +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use taler_common::{api_common::{EddsaPublicKey, ShortHashCode}, types::url}; + + use crate::{ + db::IncomingType, + subject::{ + IncomingSubject, IncomingSubjectErr, OutgoingSubject, fmt_out_subject, mod10_recursive, + parse_incoming_unstructured, parse_outgoing, subject_fmt_qr_bill, subject_is_qr_bill, + }, + }; + + #[test] + fn qrbill() { + let reference = "210000000003139471430009017"; + + let input = "21000000000313947143000901"; + let carry = mod10_recursive(input.as_bytes()); + let checksum = (10 - carry) % 10; + assert_eq!(checksum, 7); + + assert_eq!(mod10_recursive(reference.as_bytes()), 0); + assert!(subject_is_qr_bill(reference)); + assert!(!subject_is_qr_bill(input)); + assert!(!subject_is_qr_bill("")); + assert!(!subject_is_qr_bill("210000000003139471430009019")); + assert!(!subject_is_qr_bill("21000000000313947143000901A")); + + let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; + let key = EddsaPublicKey::from_str(key).unwrap(); + assert_eq!( + subject_fmt_qr_bill(key.as_ref()), + "442862674560948379842733643" + ); + } + + #[test] + /** Test parsing logic */ + fn incoming_parse() { + let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; + let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; + + // Common checks + for ty in [IncomingType::reserve, IncomingType::kyc, IncomingType::map] { + let prefix = match ty { + IncomingType::reserve => "", + IncomingType::kyc => "KYC", + IncomingType::map => "MAP", + }; + let standard = &format!("{prefix}{key}"); + let (standard_l, standard_r) = standard.split_at(standard.len() / 2); + let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); + let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); + let other_standard = &format!("{prefix}{other}"); + let other_mixed = + &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); + let key = EddsaPublicKey::from_str(key).unwrap(); + let result = Ok(Some(match ty { + IncomingType::reserve => IncomingSubject::Reserve(key), + IncomingType::kyc => IncomingSubject::Kyc(key), + IncomingType::map => IncomingSubject::Map(key), + })); + + // Check succeed if standard or mixed + for case in [standard, mixed] { + for test in [ + format!("noise {case} noise"), + format!("{case} noise to the right"), + format!("noise to the left {case}"), + format!(" {case} "), + format!("noise\n{case}\nnoise"), + format!("Test+{case}"), + ] { + assert_eq!(parse_incoming_unstructured(&test), result); + } } - IncomingType::kyc => IncomingSubject::Kyc(EddsaPublicKey::from_str(key).unwrap()), - IncomingType::wad => unreachable!(), - })); - - // Check succeed if standard or mixed - for case in [standard, mixed] { - for test in [ - format!("noise {case} noise"), - format!("{case} noise to the right"), - format!("noise to the left {case}"), - format!(" {case} "), - format!("noise\n{case}\nnoise"), - format!("Test+{case}"), - ] { - assert_eq!(parse_incoming_unstructured(&test), result); + + // Check succeed if standard or mixed and split + for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] { + for case in [ + format!("left {l}{r} right"), + format!("left {l} {r} right"), + format!("left {l}-{r} right"), + format!("left {l}+{r} right"), + format!("left {l}\n{r} right"), + format!("left {l}-+\n{r} right"), + format!("left {l} - {r} right"), + format!("left {l} + {r} right"), + format!("left {l} \n {r} right"), + format!("left {l} - + \n {r} right"), + ] { + assert_eq!(parse_incoming_unstructured(&case), result); + } + } + + // Check concat parts + for chunk_size in 1..standard.len() { + let chunked: String = standard + .as_bytes() + .chunks(chunk_size) + .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) + .collect(); + for case in [chunked.clone(), format!("left {chunked} right")] { + assert_eq!(parse_incoming_unstructured(&case), result); + } } - } - // Check succeed if standard or mixed and split - for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] { + // Check failed when multiple key for case in [ - format!("left {l}{r} right"), - format!("left {l} {r} right"), - format!("left {l}-{r} right"), - format!("left {l}+{r} right"), - format!("left {l}\n{r} right"), - format!("left {l}-+\n{r} right"), - format!("left {l} - {r} right"), - format!("left {l} + {r} right"), - format!("left {l} \n {r} right"), - format!("left {l} - + \n {r} right"), + format!("{standard} {other_standard}"), + format!("{mixed} {other_mixed}"), ] { - assert_eq!(parse_incoming_unstructured(&case), result); + assert_eq!( + parse_incoming_unstructured(&case), + Err(IncomingSubjectErr::Ambiguous) + ); } - } - // Check concat parts - for chunk_size in 1..standard.len() { - let chunked: String = standard - .as_bytes() - .chunks(chunk_size) - .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) - .collect(); - for case in [chunked.clone(), format!("left {chunked} right")] { + // Check accept redundant key + for case in [ + format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key + format!("{standard} {other_mixed}"), // Prefer high quality + ] { assert_eq!(parse_incoming_unstructured(&case), result); } - } - // Check failed when multiple key - for case in [ - format!("{standard} {other_standard}"), - format!("{mixed} {other_mixed}"), - ] { - assert_eq!( - parse_incoming_unstructured(&case), - Err(IncomingSubjectErr::Ambiguous) - ); - } + // Check prefer prefixed over simple + for case in [format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}")] { + let res = parse_incoming_unstructured(&case); + if ty == IncomingType::reserve { + assert_eq!(res, Err(IncomingSubjectErr::Ambiguous)); + } else { + assert_eq!(res, result); + } + } - // Check accept redundant key - for case in [ - format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key - format!("{standard} {other_mixed}"), // Prefer high quality - ] { - assert_eq!(parse_incoming_unstructured(&case), result); - } + // Check failure if malformed or missing + for case in [ + "does not contain any reserve", // Check fail if none + &standard[1..], // Check fail if missing char + //"2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key + ] { + assert_eq!(parse_incoming_unstructured(case), Ok(None)); + } - // Check prefer prefixed over simple - for case in [format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}")] { - let res = parse_incoming_unstructured(&case); - if ty == IncomingType::reserve { - assert_eq!(res, Err(IncomingSubjectErr::Ambiguous)); - } else { - assert_eq!(res, result); + if ty == IncomingType::kyc || ty == IncomingType::map { + // Prefer prefixed over unprefixed + for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { + assert_eq!(parse_incoming_unstructured(&case), result); + } } } + } - // Check failure if malformed or missing - for case in [ - "does not contain any reserve", // Check fail if none - &standard[1..], // Check fail if missing char - //"2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key + #[test] + /** Test parsing logic using real cases */ + fn real() { + // Good reserve case + for (subject, key) in [ + ( + "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", + "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + ), + ( + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + ), + ( + "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", + "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + ), + ( + "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", + "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", + ), ] { - assert_eq!(parse_incoming_unstructured(case), Ok(None)); + assert_eq!( + Ok(Some(IncomingSubject::Reserve( + EddsaPublicKey::from_str(key).unwrap(), + ))), + parse_incoming_unstructured(subject) + ) } - - if ty == IncomingType::kyc { - // Prefer prefixed over unprefixed - for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { - assert_eq!(parse_incoming_unstructured(&case), result); - } + // Good kyc case + for (subject, key) in [( + "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70", + "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70", + )] { + assert_eq!( + Ok(Some(IncomingSubject::Kyc( + EddsaPublicKey::from_str(key).unwrap(), + ))), + parse_incoming_unstructured(subject) + ) } } -} -#[test] -/** Test parsing logic using real cases */ -fn real() { - // Good reserve case - for (subject, key) in [ - ( - "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", - "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - ), - ( - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - ), - ( - "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", - "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - ), - ( - "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", - "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", - ), - ] { + #[test] + fn outgoing() { + let key = ShortHashCode::rand(); + + // Without metadata + let subject = format!("{key} http://exchange.example.com/"); + let parsed = parse_outgoing(&subject).unwrap(); assert_eq!( - Ok(Some(IncomingSubject::Reserve( - EddsaPublicKey::from_str(key).unwrap(), - ))), - parse_incoming_unstructured(subject) - ) - } - // Good kyc case - for (subject, key) in [( - "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70", - "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70", - )] { + parsed, + OutgoingSubject { + wtid: key.clone(), + exchange_base_url: url("http://exchange.example.com/"), + metadata: None + } + ); assert_eq!( - Ok(Some(IncomingSubject::Kyc( - EddsaPublicKey::from_str(key).unwrap(), - ))), - parse_incoming_unstructured(subject) - ) + subject, + fmt_out_subject( + &parsed.wtid, + &parsed.exchange_base_url, + parsed.metadata.as_deref() + ) + ); + + // With metadata + let subject = format!("Accounting:id.4 {key} http://exchange.example.com/"); + let parsed = parse_outgoing(&subject).unwrap(); + assert_eq!( + parsed, + OutgoingSubject { + wtid: key.clone(), + exchange_base_url: url("http://exchange.example.com/"), + metadata: Some("Accounting:id.4".into()) + } + ); + assert_eq!( + subject, + fmt_out_subject( + &parsed.wtid, + &parsed.exchange_base_url, + parsed.metadata.as_deref() + ) + ); } } - -#[test] -fn outgoing() { - let key = ShortHashCode::rand(); - - // Without metadata - let subject = format!("{key} http://exchange.example.com/"); - let parsed = parse_outgoing(&subject).unwrap(); - assert_eq!( - parsed, - OutgoingSubject { - wtid: key.clone(), - exchange_base_url: url("http://exchange.example.com/"), - metadata: None - } - ); - assert_eq!( - subject, - fmt_out_subject( - &parsed.wtid, - &parsed.exchange_base_url, - parsed.metadata.as_deref() - ) - ); - - // With metadata - let subject = format!("Accounting:id.4 {key} http://exchange.example.com/"); - let parsed = parse_outgoing(&subject).unwrap(); - assert_eq!( - parsed, - OutgoingSubject { - wtid: key.clone(), - exchange_base_url: url("http://exchange.example.com/"), - metadata: Some("Accounting:id.4".into()) - } - ); - assert_eq!( - subject, - fmt_out_subject( - &parsed.wtid, - &parsed.exchange_base_url, - parsed.metadata.as_deref() - ) - ); -} diff --git a/common/taler-api/tests/common/db.rs b/common/taler-api/tests/common/db.rs @@ -109,7 +109,9 @@ pub async fn transfer_page( amount, credit_payto, created_at - FROM transfer WHERE + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE ", ); if let Some(status) = status { @@ -147,7 +149,9 @@ pub async fn transfer_by_id( wtid, credit_payto, created_at - FROM transfer WHERE transfer_id = $1 + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE transfer_id = $1 ", ) .bind(id as i64) @@ -189,7 +193,9 @@ pub async fn outgoing_revenue( wtid, credit_payto, created_at - FROM transfer WHERE status = 'success' AND + FROM transfer + JOIN tx_out USING (tx_out_id) + WHERE status = 'success' AND ", ) }, @@ -270,9 +276,12 @@ pub async fn incoming_history( amount, created_at, debit_payto, - metadata, - origin_exchange_url - FROM tx_in WHERE + account_pub, + authorization_pub, + authorization_sig + FROM tx_in + JOIN taler_in USING (tx_in_id) + WHERE ", ) }, @@ -284,9 +293,9 @@ pub async fn incoming_history( amount: r.try_get_amount("amount", currency)?, credit_fee: None, debit_account: r.try_get_payto("debit_payto")?, - reserve_pub: r.try_get("metadata")?, - authorization_pub: None, - authorization_sig: None, + reserve_pub: r.try_get("account_pub")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { row_id: r.try_get_safeu64("tx_in_id")?, @@ -294,18 +303,11 @@ pub async fn incoming_history( amount: r.try_get_amount("amount", currency)?, credit_fee: None, debit_account: r.try_get_payto("debit_payto")?, - account_pub: r.try_get("metadata")?, - authorization_pub: None, - authorization_sig: None, - }, - IncomingType::wad => IncomingBankTransaction::Wad { - row_id: r.try_get_safeu64("tx_in_id")?, - date: r.try_get_timestamp("created_at")?.into(), - amount: r.try_get_amount("amount", currency)?, - debit_account: r.try_get_payto("debit_payto")?, - origin_exchange_url: r.try_get_url("origin_exchange_url")?, - wad_id: r.try_get("metadata")?, + account_pub: r.try_get("account_pub")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, }, + IncomingType::map => unimplemented!("MAP are never listed in the history"), }) }, ) @@ -332,7 +334,8 @@ pub async fn revenue_history( created_at, debit_payto, subject - FROM tx_in WHERE + FROM tx_in + WHERE ", ) }, diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -495,5 +495,12 @@ mod test { TransferPayto::from_str(&transfer_payto.as_payto().to_string()).unwrap() ); assert_eq!(full_payto, transfer_payto.clone().into()); + + let malformed = FullPayto::<IBAN>::from_str( + "payto://iban/CH0400766000103138557?receiver-name=NYM%20Technologies%SA", + ) + .unwrap(); + assert_eq!(malformed.as_ref().to_string(), "CH0400766000103138557"); + assert_eq!(malformed.name, "NYM Technologies%SA"); } } diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -481,7 +481,7 @@ async fn add_incoming_routine( let (path, key) = match kind { IncomingType::reserve => ("/taler-wire-gateway/admin/add-incoming", "reserve_pub"), IncomingType::kyc => ("/taler-wire-gateway/admin/add-kycauth", "account_pub"), - IncomingType::wad => unreachable!(), + IncomingType::map => unreachable!(), }; let valid_req = json!({ "amount": format!("{currency}:44"), @@ -507,7 +507,7 @@ async fn add_incoming_routine( // Non conflict on reuse server.post(path).json(&valid_req).await.assert_ok(); } - IncomingType::wad => unreachable!(), + IncomingType::map => unreachable!(), } // Currency mismatch diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -587,9 +587,7 @@ pub async fn incoming_history( authorization_pub: None, authorization_sig: None, }, - IncomingType::wad => { - unimplemented!("WAD is not yet supported") - } + IncomingType::map => unimplemented!("MAP are never listed in the history"), }) }, ) diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -601,9 +601,7 @@ pub async fn incoming_history( authorization_pub: None, authorization_sig: None, }, - IncomingType::wad => { - unimplemented!("WAD is not yet supported") - } + IncomingType::map => unimplemented!("MAP are never listed in the history"), }) }, )