commit 4836612545adef76c0329c47e620f7ab34a2032d parent 69ad6f8d355377b5d534b87455269e2378a83826 Author: Antoine A <> Date: Sat, 21 Mar 2026 15:32:00 +0100 common: support new metadata field in wire gateway API v5 Diffstat:
20 files changed, 317 insertions(+), 75 deletions(-)
diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -26,6 +26,7 @@ sqlx.workspace = true jiff.workspace = true aws-lc-rs.workspace = true compact_str.workspace = true +regex.workspace = true [dev-dependencies] taler-test-utils.workspace = true diff --git a/common/taler-api/db/taler-api-0002.sql b/common/taler-api/db/taler-api-0002.sql @@ -0,0 +1,21 @@ +-- +-- 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 @@ -1,6 +1,6 @@ -- -- This file is part of TALER --- Copyright (C) 2025 Taler Systems SA +-- Copyright (C) 2025, 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 @@ -41,6 +41,7 @@ $do$; CREATE FUNCTION taler_transfer( IN in_amount taler_amount, IN in_exchange_base_url TEXT, + IN in_metadata TEXT, IN in_subject TEXT, IN in_credit_payto TEXT, IN in_request_uid BYTEA, @@ -59,7 +60,8 @@ BEGIN SELECT (amount != in_amount OR credit_payto != in_credit_payto OR exchange_base_url != in_exchange_base_url - OR wtid != in_wtid) + OR wtid != in_wtid + OR metadata != in_metadata) ,transfer_id, created_at INTO out_request_uid_reuse, out_transfer_row_id, out_created_at FROM transfer @@ -77,6 +79,7 @@ out_created_at=in_now; INSERT INTO transfer ( amount, exchange_base_url, + metadata, subject, credit_payto, request_uid, @@ -87,6 +90,7 @@ INSERT INTO transfer ( ) VALUES ( in_amount, in_exchange_base_url, + in_metadata, in_subject, in_credit_payto, in_request_uid, diff --git a/common/taler-api/src/api/wire.rs b/common/taler-api/src/api/wire.rs @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use axum::{ Json, Router, @@ -23,6 +23,7 @@ use axum::{ response::IntoResponse as _, routing::{get, post}, }; +use regex::Regex; use taler_common::{ api_params::{AccountParams, History, HistoryParams, Page, TransferParams}, api_wire::{ @@ -90,6 +91,9 @@ pub trait WireGateway: TalerApi { } } +static METADATA_PATTERN: LazyLock<Regex> = + LazyLock::new(|| Regex::new("^[a-zA-Z0-9-.:]{1, 40}$").unwrap()); + pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { Router::new() .route( @@ -97,6 +101,17 @@ pub fn router<I: WireGateway>(state: Arc<I>, auth: AuthMethod) -> Router { post( async |State(state): State<Arc<I>>, Req(req): Req<TransferRequest>| { state.check_currency(&req.amount)?; + if let Some(metadata) = &req.metadata + && !METADATA_PATTERN.is_match(metadata) + { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format!( + "metadata '{metadata}' is malformed, must match {}", + METADATA_PATTERN.as_str() + ), + )); + } ApiResult::Ok(Json(state.transfer(req).await?)) }, ), diff --git a/common/taler-api/src/subject.rs b/common/taler-api/src/subject.rs @@ -147,7 +147,7 @@ pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectE } /// Format an outgoing subject -pub fn fmt_outgoing_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&str>) -> String { +pub fn fmt_out_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&str>) -> String { let mut buf = String::new(); if let Some(metadata) = metadata { buf.push_str(metadata); @@ -157,6 +157,15 @@ pub fn fmt_outgoing_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&s buf } +/// Format an incoming subject +pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> String { + match ty { + IncomingType::reserve => format!("{key}"), + IncomingType::kyc => format!("KYC:{key}"), + IncomingType::wad => unreachable!(), + } +} + /** * Extract the public key from an unstructured incoming transfer subject. * @@ -426,3 +435,48 @@ fn real() { ) } } + +#[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 @@ -19,6 +19,7 @@ use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ db::{BindHelper, IncomingType, TypeHelper, history, page}, serialized, + subject::fmt_out_subject, }; use taler_common::{ api_common::{EddsaPublicKey, SafeU64}, @@ -56,21 +57,22 @@ pub enum TransferResult { WtidReuse, } -pub async fn transfer(db: &PgPool, transfer: &TransferRequest) -> sqlx::Result<TransferResult> { - let subject = &format!("{} {}", transfer.wtid, transfer.exchange_base_url); +pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<TransferResult> { + let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); serialized!( sqlx::query( " SELECT out_request_uid_reuse, out_wtid_reuse, out_transfer_row_id, out_created_at - FROM taler_transfer($1, $2, $3, $4, $5, $6, $7) + FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8) ", ) - .bind(&transfer.amount) - .bind(transfer.exchange_base_url.as_str()) - .bind(subject) - .bind(transfer.credit_account.raw()) - .bind(&transfer.request_uid) - .bind(&transfer.wtid) + .bind(&req.amount) + .bind(req.exchange_base_url.as_str()) + .bind(&req.metadata) + .bind(&subject) + .bind(req.credit_account.raw()) + .bind(&req.request_uid) + .bind(&req.wtid) .bind_timestamp(&Timestamp::now()) .try_map(|r: PgRow| { Ok(if r.try_get_flag("out_request_uid_reuse")? { @@ -141,6 +143,7 @@ pub async fn transfer_by_id( status_msg, amount, exchange_base_url, + metadata, wtid, credit_payto, created_at @@ -154,6 +157,7 @@ pub async fn transfer_by_id( status_msg: r.try_get("status_msg")?, amount: r.try_get_amount("amount", currency)?, origin_exchange_url: r.try_get("exchange_base_url")?, + metadata: r.try_get("metadata")?, wtid: r.try_get("wtid")?, credit_account: r.try_get_payto("credit_payto")?, timestamp: r.try_get_timestamp("created_at")?.into(), @@ -181,6 +185,7 @@ pub async fn outgoing_revenue( transfer_id, amount, exchange_base_url, + metadata, wtid, credit_payto, created_at @@ -197,6 +202,7 @@ pub async fn outgoing_revenue( row_id: r.try_get_safeu64("transfer_id")?, date: r.try_get_timestamp("created_at")?.into(), exchange_base_url: r.try_get_url("exchange_base_url")?, + metadata: r.try_get("metadata")?, }) }, ) diff --git a/common/taler-api/tests/security.rs b/common/taler-api/tests/security.rs @@ -41,6 +41,7 @@ async fn body_parsing() { exchange_base_url: url("https://test.com"), wtid: Base32::rand(), credit_account: payto("payto:://test"), + metadata: None, }; // Check OK diff --git a/common/taler-common/src/api_wire.rs b/common/taler-common/src/api_wire.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + Copyright (C) 2024, 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -16,6 +16,7 @@ //! Type for the Taler Wire Gateway HTTP API <https://docs.taler.net/core/api-bank-wire.html#taler-wire-gateway-http-api> +use compact_str::CompactString; use url::Url; use crate::{ @@ -49,6 +50,7 @@ pub struct TransferRequest { pub request_uid: HashCode, pub amount: Amount, pub exchange_base_url: Url, + pub metadata: Option<CompactString>, pub wtid: ShortHashCode, pub credit_account: PaytoURI, } @@ -77,6 +79,7 @@ pub struct TransferStatus { pub status_msg: Option<String>, pub amount: Amount, pub origin_exchange_url: String, + pub metadata: Option<CompactString>, pub wtid: ShortHashCode, pub credit_account: PaytoURI, pub timestamp: TalerTimestamp, @@ -99,6 +102,7 @@ pub struct OutgoingBankTransaction { pub credit_account: PaytoURI, pub wtid: ShortHashCode, pub exchange_base_url: Url, + pub metadata: Option<CompactString>, } /// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory> diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -28,8 +28,8 @@ use taler_common::{ api_params::PageParams, api_revenue::RevenueIncomingHistory, api_wire::{ - IncomingBankTransaction, IncomingHistory, TransferList, TransferResponse, TransferState, - TransferStatus, + IncomingBankTransaction, IncomingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, }, error_code::ErrorCode, types::{amount::amount, base32::Base32, payto::PaytoURI, url}, @@ -277,11 +277,13 @@ pub async fn transfer_routine( ) { let currency = &get_currency(server).await; let default_amount = amount(format!("{currency}:42")); + let request_uid = HashCode::rand(); + let wtid = ShortHashCode::rand(); let transfer_request = json!({ - "request_uid": HashCode::rand(), + "request_uid": request_uid, "amount": default_amount, "exchange_base_url": "http://exchange.taler/", - "wtid": ShortHashCode::rand(), + "wtid": wtid, "credit_account": credit_account, }); @@ -300,23 +302,71 @@ pub async fn transfer_routine( .assert_no_content(); } - // Check create transfer - { + // TODO check subject formatting + + let routine = async |req: &TransferRequest| { // Check OK let first = server .post("/taler-wire-gateway/transfer") - .json(&transfer_request) + .json(&req) .await .assert_ok_json::<TransferResponse>(); // Check idempotent let second = server .post("/taler-wire-gateway/transfer") - .json(&transfer_request) + .json(&req) .await .assert_ok_json::<TransferResponse>(); assert_eq!(first.row_id, second.row_id); assert_eq!(first.timestamp, second.timestamp); + // Check by id + let tx = server + .get(&format!("/taler-wire-gateway/transfers/{}", first.row_id)) + .await + .assert_ok_json::<TransferStatus>(); + assert_eq!(default_status, tx.status); + assert_eq!(default_amount, tx.amount); + assert_eq!("http://exchange.taler/", tx.origin_exchange_url); + assert_eq!(req.wtid, tx.wtid); + assert_eq!(first.timestamp, tx.timestamp); + assert_eq!(req.metadata, tx.metadata); + assert_eq!(credit_account, &tx.credit_account); + + // Check page + let list = server + .get("/taler-wire-gateway/transfers?limit=-1") + .await + .assert_ok_json::<TransferList>(); + let tx = &list.transfers[0]; + assert_eq!(first.row_id, tx.row_id); + assert_eq!(default_status, tx.status); + assert_eq!(default_amount, tx.amount); + assert_eq!(first.timestamp, tx.timestamp); + assert_eq!(credit_account, &tx.credit_account); + }; + + let req = TransferRequest { + request_uid, + amount: default_amount.clone(), + exchange_base_url: url("http://exchange.taler/"), + metadata: None, + wtid, + credit_account: credit_account.clone(), + }; + // Simple + routine(&req).await; + // With metadata + routine(&TransferRequest { + request_uid: HashCode::rand(), + wtid: ShortHashCode::rand(), + metadata: Some("test:medatata".into()), + ..req + }) + .await; + + // Check create transfer errors + { // Check request uid reuse server .post("/taler-wire-gateway/transfer") @@ -342,31 +392,21 @@ pub async fn transfer_routine( })) .await .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + + // Malformed metadata + for metadata in ["bad_id", "bad id", "bad@id.com", &"A".repeat(41)] { + server + .post("/taler-wire-gateway/transfer") + .json(&json!(transfer_request + { + "metadata": metadata + })) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); + } } - // Check transfer by id + // Check transfer by id errors { - let wtid = ShortHashCode::rand(); - let resp = server - .post("/taler-wire-gateway/transfer") - .json(&json!(transfer_request + { - "request_uid": HashCode::rand(), - "wtid": wtid, - })) - .await - .assert_ok_json::<TransferResponse>(); - - // Check OK - let tx = server - .get(&format!("/taler-wire-gateway/transfers/{}", resp.row_id)) - .await - .assert_ok_json::<TransferStatus>(); - assert_eq!(default_status, tx.status); - assert_eq!(default_amount, tx.amount); - assert_eq!("http://exchange.taler/", tx.origin_exchange_url); - assert_eq!(wtid, tx.wtid); - assert_eq!(resp.timestamp, tx.timestamp); - // Check unknown transaction server .get("/taler-wire-gateway/transfers/42") diff --git a/taler-cyclos/db/cyclos-0002.sql b/taler-cyclos/db/cyclos-0002.sql @@ -0,0 +1,22 @@ +-- +-- 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('cyclos-0002', NULL, NULL); + +SET search_path TO cyclos; + +-- Add outgoing transactions metadata field +ALTER TABLE taler_out ADD COLUMN metadata TEXT; +ALTER TABLE transfer ADD COLUMN metadata TEXT; +\ No newline at end of file diff --git a/taler-cyclos/db/cyclos-procedures.sql b/taler-cyclos/db/cyclos-procedures.sql @@ -1,6 +1,6 @@ -- -- This file is part of TALER --- Copyright (C) 2025 Taler Systems SA +-- Copyright (C) 2025, 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 @@ -127,6 +127,7 @@ CREATE FUNCTION register_tx_out( IN in_valued_at INT8, IN in_wtid BYTEA, IN in_origin_exchange_url TEXT, + IN in_metadata TEXT, IN in_bounced INT8, IN in_now INT8, -- Success return @@ -194,11 +195,13 @@ IF in_wtid IS NOT NULL THEN INSERT INTO taler_out ( tx_out_id, wtid, - exchange_base_url + exchange_base_url, + metadata ) VALUES ( out_tx_row_id, in_wtid, - in_origin_exchange_url + in_origin_exchange_url, + in_metadata ) ON CONFLICT (wtid) DO NOTHING; IF FOUND THEN -- Notify new outgoing talerable transaction registration @@ -249,6 +252,7 @@ CREATE FUNCTION taler_transfer( IN in_subject TEXT, IN in_amount taler_amount, IN in_exchange_base_url TEXT, + IN in_metadata TEXT, IN in_credit_account INT8, IN in_credit_name TEXT, IN in_now INT8, @@ -265,7 +269,8 @@ BEGIN SELECT (amount != in_amount OR credit_account != in_credit_account OR exchange_base_url != in_exchange_base_url - OR wtid != in_wtid) + OR wtid != in_wtid + OR metadata != in_metadata) ,initiated_id, initiated_at INTO out_request_uid_reuse, out_initiated_row_id, out_initiated_at FROM transfer JOIN initiated USING (initiated_id) @@ -299,12 +304,14 @@ INSERT INTO transfer ( initiated_id, request_uid, wtid, - exchange_base_url + exchange_base_url, + metadata ) VALUES ( out_initiated_row_id, in_request_uid, in_wtid, - in_exchange_base_url + in_exchange_base_url, + in_metadata ); PERFORM pg_notify('transfer', out_initiated_row_id || ''); END $$; diff --git a/taler-cyclos/src/api.rs b/taler-cyclos/src/api.rs @@ -102,6 +102,7 @@ impl WireGateway for CyclosApi { request_uid: req.request_uid, amount: req.amount.decimal(), exchange_base_url: req.exchange_base_url, + metadata: req.metadata, wtid: req.wtid, creditor_id: *creditor.id, creditor_name: creditor.name, diff --git a/taler-cyclos/src/bin/cyclos-harness.rs b/taler-cyclos/src/bin/cyclos-harness.rs @@ -186,6 +186,7 @@ impl<'a> Harness<'a> { request_uid: HashCode::rand(), amount, exchange_base_url: url("https://test.com"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id, creditor_name: CompactString::new(creditor_name), diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -23,7 +23,7 @@ use sqlx::{PgConnection, PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ db::{BindHelper, IncomingType, TypeHelper, history, page}, serialized, - subject::{IncomingSubject, OutgoingSubject}, + subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; use taler_common::{ api_common::{HashCode, ShortHashCode}, @@ -311,7 +311,7 @@ pub async fn register_tx_out( let query = sqlx::query( " SELECT out_result, out_tx_row_id - FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ", ) .bind(tx.transfer_id) @@ -325,13 +325,17 @@ pub async fn register_tx_out( TxOutKind::Simple => query .bind(None::<&[u8]>) .bind(None::<&str>) + .bind(None::<&str>) .bind(None::<i64>), - TxOutKind::Bounce(bounced) => { - query.bind(None::<&[u8]>).bind(None::<&str>).bind(*bounced) - } + TxOutKind::Bounce(bounced) => query + .bind(None::<&[u8]>) + .bind(None::<&str>) + .bind(None::<&str>) + .bind(*bounced), TxOutKind::Talerable(subject) => query .bind(&subject.wtid) .bind(subject.exchange_base_url.as_str()) + .bind(&subject.metadata) .bind(None::<i64>), }; query @@ -358,6 +362,7 @@ pub struct Transfer { pub request_uid: HashCode, pub amount: Decimal, pub exchange_base_url: Url, + pub metadata: Option<CompactString>, pub wtid: ShortHashCode, pub creditor_id: i64, pub creditor_name: CompactString, @@ -368,12 +373,12 @@ pub async fn make_transfer( tx: &Transfer, now: &Timestamp, ) -> sqlx::Result<TransferResult> { - let subject = format!("{} {}", tx.wtid, tx.exchange_base_url); + let subject = fmt_out_subject(&tx.wtid, &tx.exchange_base_url, tx.metadata.as_deref()); serialized!( sqlx::query( " SELECT out_request_uid_reuse, out_wtid_reuse, out_initiated_row_id, out_initiated_at - FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8) + FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8, $9) ", ) .bind(&tx.request_uid) @@ -381,6 +386,7 @@ pub async fn make_transfer( .bind(&subject) .bind(tx.amount) .bind(tx.exchange_base_url.as_str()) + .bind(&tx.metadata) .bind(tx.creditor_id) .bind(&tx.creditor_name) .bind_timestamp(now) @@ -506,6 +512,7 @@ pub async fn outgoing_history( credit_name, valued_at, exchange_base_url, + metadata, wtid FROM taler_out JOIN tx_out USING (tx_out_id) @@ -521,7 +528,8 @@ pub async fn outgoing_history( credit_account: r.try_get_cyclos_fullpaytouri(2, 3, root)?, date: r.try_get_timestamp(4)?.into(), exchange_base_url: r.try_get_url(5)?, - wtid: r.try_get(6)?, + metadata: r.try_get(6)?, + wtid: r.try_get(7)?, }) }, ) @@ -643,6 +651,7 @@ pub async fn transfer_by_id( status_msg, amount, exchange_base_url, + metadata, wtid, credit_account, credit_name, @@ -659,9 +668,10 @@ pub async fn transfer_by_id( status_msg: r.try_get(1)?, amount: r.try_get_amount(2, currency)?, origin_exchange_url: r.try_get(3)?, - wtid: r.try_get(4)?, - credit_account: r.try_get_cyclos_fullpaytouri(5, 6, root)?, - timestamp: r.try_get_timestamp(7)?.into(), + metadata: r.try_get(4)?, + wtid: r.try_get(5)?, + credit_account: r.try_get_cyclos_fullpaytouri(6, 7, root)?, + timestamp: r.try_get_timestamp(8)?.into(), }) }) .fetch_optional(db) @@ -1131,6 +1141,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("10"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into() @@ -1250,6 +1261,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("10"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into(), @@ -1467,6 +1479,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("1"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into(), @@ -1488,6 +1501,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("1"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into(), @@ -1550,6 +1564,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal(format!("{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into(), @@ -1572,6 +1587,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal(format!("{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor_id: 31000163100000000, creditor_name: "Name".into(), diff --git a/taler-magnet-bank/Cargo.toml b/taler-magnet-bank/Cargo.toml @@ -35,6 +35,7 @@ failure-injection.workspace = true hyper.workspace = true url.workspace = true aws-lc-rs.workspace = true +compact_str.workspace = true [dev-dependencies] taler-test-utils.workspace = true diff --git a/taler-magnet-bank/db/magnet-bank-0002.sql b/taler-magnet-bank/db/magnet-bank-0002.sql @@ -0,0 +1,22 @@ +-- +-- 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('magnet-bank-0002', NULL, NULL); + +SET search_path TO magnet_bank; + +-- Add outgoing transactions metadata field +ALTER TABLE taler_out ADD COLUMN metadata TEXT; +ALTER TABLE transfer ADD COLUMN metadata TEXT; +\ No newline at end of file diff --git a/taler-magnet-bank/db/magnet-bank-procedures.sql b/taler-magnet-bank/db/magnet-bank-procedures.sql @@ -1,6 +1,6 @@ -- -- This file is part of TALER --- Copyright (C) 2025 Taler Systems SA +-- Copyright (C) 2025, 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 @@ -123,6 +123,7 @@ CREATE FUNCTION register_tx_out( IN in_valued_at INT8, IN in_wtid BYTEA, IN in_origin_exchange_url TEXT, + IN in_metadata TEXT, IN in_bounced INT8, IN in_now INT8, -- Success return @@ -180,11 +181,13 @@ IF in_wtid IS NOT NULL THEN INSERT INTO taler_out ( tx_out_id, wtid, - exchange_base_url + exchange_base_url, + metadata ) VALUES ( out_tx_row_id, in_wtid, - in_origin_exchange_url + in_origin_exchange_url, + in_metadata ) ON CONFLICT (wtid) DO NOTHING; IF FOUND THEN -- Notify new outgoing talerable transaction registration @@ -240,6 +243,7 @@ CREATE FUNCTION taler_transfer( IN in_subject TEXT, IN in_amount taler_amount, IN in_exchange_base_url TEXT, + IN in_metadata TEXT, IN in_credit_account TEXT, IN in_credit_name TEXT, IN in_now INT8, @@ -256,7 +260,8 @@ BEGIN SELECT (amount != in_amount OR credit_account != in_credit_account OR exchange_base_url != in_exchange_base_url - OR wtid != in_wtid) + OR wtid != in_wtid + OR metadata != in_metadata) ,initiated_id, initiated_at INTO out_request_uid_reuse, out_initiated_row_id, out_initiated_at FROM transfer JOIN initiated USING (initiated_id) @@ -290,12 +295,14 @@ INSERT INTO transfer ( initiated_id, request_uid, wtid, - exchange_base_url + exchange_base_url, + metadata ) VALUES ( out_initiated_row_id, in_request_uid, in_wtid, - in_exchange_base_url + in_exchange_base_url, + in_metadata ); PERFORM pg_notify('transfer', out_initiated_row_id || ''); END $$; diff --git a/taler-magnet-bank/src/api.rs b/taler-magnet-bank/src/api.rs @@ -93,6 +93,7 @@ impl WireGateway for MagnetApi { request_uid: req.request_uid, wtid: req.wtid, amount: req.amount.decimal(), + metadata: req.metadata, creditor, exchange_base_url: req.exchange_base_url, }, diff --git a/taler-magnet-bank/src/bin/magnet-bank-harness.rs b/taler-magnet-bank/src/bin/magnet-bank-harness.rs @@ -143,6 +143,7 @@ impl<'a> Harness<'a> { request_uid: HashCode::rand(), amount: decimal(format!("{forint}")), exchange_base_url: url("https://test.com"), + metadata: None, wtid: ShortHashCode::rand(), creditor, }, diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -16,13 +16,14 @@ use std::fmt::Display; +use compact_str::CompactString; use jiff::{Timestamp, civil::Date, tz::TimeZone}; use serde::{Serialize, de::DeserializeOwned}; use sqlx::{PgConnection, PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ db::{BindHelper, IncomingType, TypeHelper, history, page}, serialized, - subject::{IncomingSubject, OutgoingSubject}, + subject::{IncomingSubject, OutgoingSubject, fmt_out_subject}, }; use taler_common::{ api_common::{HashCode, ShortHashCode}, @@ -297,7 +298,7 @@ pub async fn register_tx_out( let query = sqlx::query( " SELECT out_result, out_tx_row_id - FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ", ) .bind(tx.code as i64) @@ -310,14 +311,17 @@ pub async fn register_tx_out( TxOutKind::Simple => query .bind(None::<&[u8]>) .bind(None::<&str>) + .bind(None::<&str>) .bind(None::<i64>), TxOutKind::Bounce(bounced) => query .bind(None::<&[u8]>) .bind(None::<&str>) + .bind(None::<&str>) .bind(*bounced as i64), TxOutKind::Talerable(subject) => query .bind(&subject.wtid) .bind(subject.exchange_base_url.as_str()) + .bind(&subject.metadata) .bind(None::<i64>), }; query @@ -376,6 +380,7 @@ pub struct Transfer { pub request_uid: HashCode, pub amount: Decimal, pub exchange_base_url: Url, + pub metadata: Option<CompactString>, pub wtid: ShortHashCode, pub creditor: FullHuPayto, } @@ -385,12 +390,12 @@ pub async fn make_transfer( tx: &Transfer, now: &Timestamp, ) -> sqlx::Result<TransferResult> { - let subject = format!("{} {}", tx.wtid, tx.exchange_base_url); + let subject = fmt_out_subject(&tx.wtid, &tx.exchange_base_url, tx.metadata.as_deref()); serialized!( sqlx::query( " SELECT out_request_uid_reuse, out_wtid_reuse, out_initiated_row_id, out_initiated_at - FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8) + FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8, $9) ", ) .bind(&tx.request_uid) @@ -398,6 +403,7 @@ pub async fn make_transfer( .bind(&subject) .bind(tx.amount) .bind(tx.exchange_base_url.as_str()) + .bind(&tx.metadata) .bind(tx.creditor.iban()) .bind(&tx.creditor.name) .bind_timestamp(now) @@ -522,6 +528,7 @@ pub async fn outgoing_history( credit_name, valued_at, exchange_base_url, + metadata, wtid FROM taler_out JOIN tx_out USING (tx_out_id) @@ -537,7 +544,8 @@ pub async fn outgoing_history( credit_account: r.try_get_iban(2)?.as_full_payto(r.try_get(3)?), date: r.try_get_timestamp(4)?.into(), exchange_base_url: r.try_get_url(5)?, - wtid: r.try_get(6)?, + metadata: r.try_get(6)?, + wtid: r.try_get(7)?, }) }, ) @@ -650,6 +658,7 @@ pub async fn transfer_by_id(db: &PgPool, id: u64) -> sqlx::Result<Option<Transfe status_msg, amount, exchange_base_url, + metadata, wtid, credit_account, credit_name, @@ -666,9 +675,10 @@ pub async fn transfer_by_id(db: &PgPool, id: u64) -> sqlx::Result<Option<Transfe status_msg: r.try_get(1)?, amount: r.try_get_amount(2, &CURRENCY)?, origin_exchange_url: r.try_get(3)?, - wtid: r.try_get(4)?, - credit_account: r.try_get_iban(5)?.as_full_payto(r.try_get(6)?), - timestamp: r.try_get_timestamp(7)?.into(), + metadata: r.try_get(4)?, + wtid: r.try_get(5)?, + credit_account: r.try_get_iban(6)?.as_full_payto(r.try_get(7)?), + timestamp: r.try_get_timestamp(8)?.into(), }) }) .fetch_optional(db) @@ -1099,6 +1109,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("10"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor: tx.creditor.clone() }, @@ -1223,6 +1234,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("10"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor: magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"), }; @@ -1313,6 +1325,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal("10"), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor: magnet_payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), }; @@ -1586,6 +1599,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal(format!("{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor: magnet_payto.clone(), }, @@ -1607,6 +1621,7 @@ mod test { request_uid: HashCode::rand(), amount: decimal(format!("{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), + metadata: None, wtid: ShortHashCode::rand(), creditor: magnet_payto.clone(), },