libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 5e1fde0445c9a3603ae6a990f8baefcce4cc04da
parent 92c06e5c679fd878a4960e4d92f7aaa981f68653
Author: Antoine A <>
Date:   Sat, 28 Feb 2026 15:00:28 +0100

common: add new Wire Transfer API

Diffstat:
Mcontrib/nexus.conf | 4++++
Mdatabase-versioning/libeufin-bank-0012.sql | 3++-
Mdatabase-versioning/libeufin-bank-0015.sql | 35++++++++++++++++++++++++++++++++---
Mdatabase-versioning/libeufin-bank-procedures.sql | 527++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mdatabase-versioning/libeufin-conversion-setup.sql | 8++------
Mdatabase-versioning/libeufin-nexus-0014.sql | 28++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-nexus-procedures.sql | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3++-
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 8++++----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 6+++++-
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 39+++++++++++++++++++++++++--------------
Alibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 4++--
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 3++-
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 14+++++++++++---
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt | 10+++++++---
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 78++++++++++++++++++------------------------------------------------------------
Alibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransferDAO.kt | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 17+++++++++--------
Mlibeufin-bank/src/test/kotlin/BankIntegrationApiTest.kt | 20++++++++++----------
Mlibeufin-bank/src/test/kotlin/CoreBankApiTest.kt | 40++++++++++++++--------------------------
Mlibeufin-bank/src/test/kotlin/GcTest.kt | 3++-
Mlibeufin-bank/src/test/kotlin/WireGatewayApiTest.kt | 8++++----
Alibeufin-bank/src/test/kotlin/WireTransferApiTest.kt | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-bank/src/test/kotlin/bench.kt | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-bank/src/test/kotlin/helpers.kt | 16++++++++++------
Mlibeufin-common/src/main/kotlin/Constants.kt | 3++-
Mlibeufin-common/src/main/kotlin/Subject.kt | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mlibeufin-common/src/main/kotlin/TalerCommon.kt | 11+++++++++--
Mlibeufin-common/src/main/kotlin/TalerErrorCode.kt | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mlibeufin-common/src/main/kotlin/TalerMessage.kt | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mlibeufin-common/src/main/kotlin/crypto/CryptoUtil.kt | 25++++++++++++++++++++++++-
Mlibeufin-common/src/main/kotlin/db/types.kt | 7+++++++
Mlibeufin-common/src/main/kotlin/test/bench.kt | 6+++---
Mlibeufin-common/src/test/kotlin/SubjectTest.kt | 16++++++++++------
Mlibeufin-nexus/conf/auth.conf | 3+++
Mlibeufin-nexus/conf/test.conf | 3+++
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 14+++++++++-----
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 2++
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 37++++++++++++++++++++++---------------
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 37++++++++++++++++++++++++-------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt | 3++-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt | 9+++++++--
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-nexus/src/test/kotlin/DatabaseTest.kt | 565++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt | 4++--
Alibeufin-nexus/src/test/kotlin/WireTransferApiTest.kt | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-nexus/src/test/kotlin/bench.kt | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mlibeufin-nexus/src/test/kotlin/helpers.kt | 4++--
Mtestbench/src/test/kotlin/IntegrationTest.kt | 1+
53 files changed, 2832 insertions(+), 396 deletions(-)

diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -121,6 +121,10 @@ AUTH_METHOD = bearer # Token for bearer authentication scheme TOKEN = +[nexus-httpd-wire-transfer-api] +# Whether to serve the Wire Transfer API +ENABLED = NO + [nexus-httpd-revenue-api] # Whether to serve the Revenue API ENABLED = NO diff --git a/database-versioning/libeufin-bank-0012.sql b/database-versioning/libeufin-bank-0012.sql @@ -36,6 +36,7 @@ ALTER TABLE taler_exchange_incoming ELSE LENGTH(metadata)=32 AND origin_exchange_url IS NULL END ); -CREATE UNIQUE INDEX taler_exchange_incoming_unique_reserve_pub ON taler_exchange_incoming (metadata) WHERE type = 'reserve'; +CREATE UNIQUE INDEX taler_exchange_incoming_unique_reserve_pub + ON taler_exchange_incoming (metadata) WHERE type = 'reserve'; COMMIT; diff --git a/database-versioning/libeufin-bank-0015.sql b/database-versioning/libeufin-bank-0015.sql @@ -20,14 +20,43 @@ SELECT _v.register_patch('libeufin-bank-0015', NULL, NULL); SET search_path TO libeufin_bank; -- Allow withdrawal not linked to a bank account -ALTER TABLE taler_withdrawal_operations ALTER COLUMN wallet_bank_account DROP NOT NULL; - -- Store the exchange account ID instead of the payto -ALTER TABLE taler_withdrawal_operations ADD COLUMN exchange_bank_account INT8 REFERENCES bank_accounts(bank_account_id) ON DELETE SET NULL; +-- Support non reserve withdrawals +ALTER TABLE taler_withdrawal_operations ALTER COLUMN wallet_bank_account DROP NOT NULL, + ADD COLUMN exchange_bank_account INT8 REFERENCES bank_accounts(bank_account_id) ON DELETE SET NULL, + ADD COLUMN type taler_incoming_type NOT NULL DEFAULT 'reserve', + DROP CONSTRAINT taler_withdrawal_operations_reserve_pub_key; UPDATE taler_withdrawal_operations SET exchange_bank_account=(SELECT bank_account_id FROM bank_accounts WHERE internal_payto=selected_exchange_payto); ALTER TABLE taler_withdrawal_operations DROP COLUMN selected_exchange_payto; +CREATE UNIQUE INDEX taler_withdrawal_operations_unique_reserve_pub + ON taler_withdrawal_operations (reserve_pub) WHERE type = 'reserve'; -- Add outgoing transactions metadata field ALTER TABLE transfer_operations ADD COLUMN metadata TEXT; +-- Replace unused wad type with new mapping type +ALTER TYPE taler_incoming_type RENAME VALUE 'wad' TO 'map'; + +CREATE TABLE prepared_transfers ( + type taler_incoming_type NOT NULL, + account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), + authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32), + recurrent BOOLEAN NOT NULL, + withdrawal_id INT8 UNIQUE REFERENCES taler_withdrawal_operations(withdrawal_id), + registered_at INT8 NOT NULL, + bank_transaction_id INT8 UNIQUE REFERENCES bank_account_transactions(bank_transaction_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX prepared_transfers_unique_reserve_pub + ON prepared_transfers (account_pub) WHERE type = 'reserve'; +CREATE INDEX prepared_transfers_timestamp + ON prepared_transfers (registered_at); + +CREATE TABLE pending_recurrent_incoming_transactions( + bank_transaction_id INT8 NOT NULL UNIQUE REFERENCES bank_account_transactions(bank_transaction_id) ON DELETE CASCADE, + debtor_account_id INT8 NOT NULL REFERENCES bank_accounts(bank_account_id) ON DELETE CASCADE, + authorization_pub BYTEA NOT NULL REFERENCES prepared_transfers(authorization_pub) +); +CREATE INDEX pending_recurrent_incoming_transactions_auth_pub + ON pending_recurrent_incoming_transactions (authorization_pub); + COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -626,12 +626,13 @@ UPDATE customers SET deleted_at = in_timestamp WHERE customer_id = my_customer_i END $$; COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is zero'; -CREATE PROCEDURE register_incoming( +CREATE FUNCTION register_incoming( IN in_tx_row_id INT8, IN in_type taler_incoming_type, IN in_metadata BYTEA, IN in_account_id INT8 ) +RETURNS void LANGUAGE plpgsql AS $$ DECLARE local_amount taler_amount; @@ -656,9 +657,133 @@ END IF; -- Notify new incoming transaction PERFORM pg_notify('bank_incoming_tx', in_account_id || ' ' || in_tx_row_id); END $$; -COMMENT ON PROCEDURE register_incoming +COMMENT ON FUNCTION register_incoming IS 'Register a bank transaction as a taler incoming transaction and announce it'; +CREATE FUNCTION bounce( + IN in_debtor_account_id INT8, + IN in_credit_transaction_id INT8, + IN in_bounce_cause TEXT, + IN in_timestamp INT8 +) +RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE +local_creditor_account_id INT8; +local_amount taler_amount; +BEGIN +-- Load transaction info +SELECT (amount).frac, (amount).val, bank_account_id +INTO local_amount.frac, local_amount.val, local_creditor_account_id +FROM bank_account_transactions +WHERE bank_transaction_id=in_credit_transaction_id; + +-- No error can happens because an opposite transaction already took place in the same transaction +PERFORM bank_wire_transfer( + in_debtor_account_id, + local_creditor_account_id, + 'Bounce ' || in_credit_transaction_id || ': ' || in_bounce_cause, + local_amount, + in_timestamp, + NULL, + NULL, + NULL +); + +-- Delete from pending if any +DELETE FROM pending_recurrent_incoming_transactions WHERE bank_transaction_id = in_credit_transaction_id; +END$$; + +CREATE FUNCTION make_incoming( + IN in_creditor_account_id INT8, + IN in_debtor_account_id INT8, + IN in_subject TEXT, + IN in_amount taler_amount, + IN in_timestamp INT8, + IN in_type taler_incoming_type, + IN in_metadata BYTEA, + IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, + -- Error status + OUT out_balance_insufficient BOOLEAN, + OUT out_bad_amount BOOLEAN, + OUT out_reserve_pub_reuse BOOLEAN, + OUT out_mapping_reuse BOOLEAN, + OUT out_unknown_mapping BOOLEAN, + -- Success return + OUT out_pending BOOLEAN, + OUT out_credit_row_id INT8, + OUT out_debit_row_id INT8 +) +LANGUAGE plpgsql AS $$ +DECLARE +local_mapped_by BYTEA; +local_withdrawal_uuid UUID; +BEGIN +out_pending=FALSE; + +-- Resolve mapping logic +IF in_type = 'map' THEN + SELECT prepared_transfers.type, account_pub, authorization_pub, withdrawal_uuid, + bank_transaction_id IS NOT NULL AND NOT recurrent, + bank_transaction_id IS NOT NULL AND recurrent + INTO in_type, in_metadata, local_mapped_by, local_withdrawal_uuid, out_mapping_reuse, out_pending + FROM prepared_transfers + LEFT JOIN taler_withdrawal_operations USING (withdrawal_id) + WHERE authorization_pub = in_metadata; + out_unknown_mapping = NOT FOUND; + IF out_unknown_mapping OR out_mapping_reuse THEN + RETURN; + END IF; +END IF; + +-- Check reserve pub reuse +out_reserve_pub_reuse=in_type = 'reserve' AND NOT out_pending AND EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_metadata AND type = 'reserve'); +IF out_reserve_pub_reuse THEN + RETURN; +END IF; + +-- Perform bank wire transfer +SELECT + transfer.out_balance_insufficient, + transfer.out_bad_amount, + transfer.out_credit_row_id, + transfer.out_debit_row_id + INTO + out_balance_insufficient, + out_bad_amount, + out_credit_row_id, + out_debit_row_id + FROM bank_wire_transfer( + in_creditor_account_id, + in_debtor_account_id, + in_subject, + in_amount, + in_timestamp, + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) as transfer; +IF out_balance_insufficient OR out_bad_amount THEN + RETURN; +END IF; + +IF out_pending THEN + -- Delay talerable registration until mapping again + INSERT INTO pending_recurrent_incoming_transactions (bank_transaction_id, debtor_account_id, authorization_pub) + VALUES (out_credit_row_id, in_debtor_account_id, local_mapped_by); +ELSE + IF local_mapped_by IS NOT NULL THEN + UPDATE prepared_transfers + SET bank_transaction_id = out_credit_row_id + WHERE authorization_pub = local_mapped_by; + PERFORM abort_taler_withdrawal(local_withdrawal_uuid); + END IF; + PERFORM register_incoming(out_credit_row_id, in_type, in_metadata, in_creditor_account_id); +END IF; +END $$; + CREATE FUNCTION taler_transfer( IN in_request_uid BYTEA, @@ -812,11 +937,7 @@ IF creditor_admin THEN PERFORM libeufin_nexus.bounce_incoming( bounce_tx ,((bounce_amount).val, (bounce_amount).frac)::libeufin_nexus.taler_amount - -- use gen_random_uuid to get some randomness - -- remove all - characters as they are not random - -- capitalise the UUID as some bank may still be case sensitive - -- end with 34 random chars which is valid for EBICS (max 35 chars) - ,upper(replace(gen_random_uuid()::text, '-', '')) + ,libeufin_nexus.ebics_id_gen() ,in_timestamp ,'exchange bounced' ); @@ -919,24 +1040,18 @@ CREATE FUNCTION taler_add_incoming( OUT out_debtor_not_found BOOLEAN, OUT out_both_exchanges BOOLEAN, OUT out_reserve_pub_reuse BOOLEAN, + OUT out_mapping_reuse BOOLEAN, + OUT out_unknown_mapping BOOLEAN, OUT out_debitor_balance_insufficient BOOLEAN, -- Success return - OUT out_tx_row_id INT8 + OUT out_tx_row_id INT8, + OUT out_pending INT8 ) LANGUAGE plpgsql AS $$ DECLARE exchange_bank_account_id INT8; sender_bank_account_id INT8; BEGIN --- Check conflict -IF in_type = 'reserve'::taler_incoming_type THEN - SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_key AND type = 'reserve') OR - EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_key) - INTO out_reserve_pub_reuse; - IF out_reserve_pub_reuse THEN - RETURN; - END IF; -END IF; -- Find exchange bank account id SELECT bank_account_id, NOT is_taler_exchange @@ -962,16 +1077,20 @@ END IF; -- Perform bank transfer SELECT out_balance_insufficient, - out_credit_row_id + out_credit_row_id, + transfer.out_reserve_pub_reuse INTO out_debitor_balance_insufficient, - out_tx_row_id - FROM bank_wire_transfer( + out_tx_row_id, + out_reserve_pub_reuse + FROM make_incoming( exchange_bank_account_id, sender_bank_account_id, in_subject, in_amount, in_timestamp, + in_type, + in_key, NULL, NULL, NULL @@ -979,8 +1098,6 @@ SELECT IF out_debitor_balance_insufficient THEN RETURN; END IF; --- Register incoming transaction -CALL register_incoming(out_tx_row_id, in_type, in_key, exchange_bank_account_id); END $$; COMMENT ON FUNCTION taler_add_incoming IS 'Create an incoming taler transaction and register it'; @@ -995,6 +1112,9 @@ CREATE FUNCTION bank_transaction( IN in_wire_transfer_fees taler_amount, IN in_min_amount taler_amount, IN in_max_amount taler_amount, + IN in_type taler_incoming_type, + IN in_metadata BYTEA, + IN in_bounce_cause TEXT, -- Error status OUT out_creditor_not_found BOOLEAN, OUT out_debtor_not_found BOOLEAN, @@ -1014,6 +1134,10 @@ CREATE FUNCTION bank_transaction( OUT out_idempotent BOOLEAN ) LANGUAGE plpgsql AS $$ +DECLARE +local_reserve_pub_reuse BOOLEAN; +local_mapping_reuse BOOLEAN; +local_unknown_mapping BOOLEAN; BEGIN -- Find credit bank account id and check it's not admin SELECT bank_account_id, is_taler_exchange, username='admin' @@ -1052,29 +1176,79 @@ ELSIF out_tan_required THEN RETURN; END IF; --- Perform bank transfer -SELECT - transfer.out_balance_insufficient, - transfer.out_bad_amount, - transfer.out_credit_row_id, - transfer.out_debit_row_id - INTO - out_balance_insufficient, - out_bad_amount, - out_credit_row_id, - out_debit_row_id - FROM bank_wire_transfer( - out_credit_bank_account_id, - out_debit_bank_account_id, - in_subject, - in_amount, - in_timestamp, - in_wire_transfer_fees, - in_min_amount, - in_max_amount - ) as transfer; -IF out_balance_insufficient OR out_bad_amount THEN - RETURN; +-- Try to perform an incoming transfer +IF out_creditor_is_exchange AND NOT out_debtor_is_exchange AND in_bounce_cause IS NULL THEN + -- Perform an incoming transfer + SELECT + transfer.out_balance_insufficient, + transfer.out_bad_amount, + transfer.out_credit_row_id, + transfer.out_debit_row_id, + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping + INTO + out_balance_insufficient, + out_bad_amount, + out_credit_row_id, + out_debit_row_id, + local_reserve_pub_reuse, + local_mapping_reuse, + local_unknown_mapping + FROM make_incoming( + out_credit_bank_account_id, + out_debit_bank_account_id, + in_subject, + in_amount, + in_timestamp, + in_type, + in_metadata, + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) as transfer; + IF out_balance_insufficient OR out_bad_amount THEN + RETURN; + END IF; + IF local_reserve_pub_reuse THEN + in_bounce_cause = 'reserve public key reuse'; + ELSIF local_mapping_reuse THEN + in_bounce_cause = 'mapping public key reuse'; + ELSIF local_unknown_mapping THEN + in_bounce_cause = 'unknown mapping public key'; + END IF; +END IF; + +IF out_credit_row_id IS NULL THEN + -- Perform common bank transfer + SELECT + transfer.out_balance_insufficient, + transfer.out_bad_amount, + transfer.out_credit_row_id, + transfer.out_debit_row_id + INTO + out_balance_insufficient, + out_bad_amount, + out_credit_row_id, + out_debit_row_id + FROM bank_wire_transfer( + out_credit_bank_account_id, + out_debit_bank_account_id, + in_subject, + in_amount, + in_timestamp, + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) as transfer; + IF out_balance_insufficient OR out_bad_amount THEN + RETURN; + END IF; +END IF; + +-- Bounce if nescessary +IF out_creditor_is_exchange AND in_bounce_cause IS NOT NULL THEN + PERFORM bounce(out_debit_bank_account_id, out_credit_row_id, in_bounce_cause, in_timestamp); END IF; -- Store operation @@ -1106,29 +1280,31 @@ DECLARE account_id INT8; amount_with_fee taler_amount; BEGIN --- Check account exists -SELECT bank_account_id, is_taler_exchange - INTO account_id, out_account_is_exchange - FROM bank_accounts - JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id - WHERE username=in_account_username AND deleted_at IS NULL; -IF NOT FOUND OR out_account_is_exchange THEN +IF in_account_username IS NOT NULL THEN + -- Check account exists + SELECT bank_account_id, is_taler_exchange + INTO account_id, out_account_is_exchange + FROM bank_accounts + JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id + WHERE username=in_account_username AND deleted_at IS NULL; out_account_not_found=NOT FOUND; - RETURN; -END IF; - --- Check enough funds -IF in_amount IS NOT NULL OR in_suggested_amount IS NOT NULL THEN - SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient( - account_id, - COALESCE(in_amount, in_suggested_amount), - in_wire_transfer_fees, - in_min_amount, - in_max_amount - ) AS test INTO out_balance_insufficient, out_bad_amount; - IF out_balance_insufficient OR out_bad_amount THEN + IF out_account_not_found OR out_account_is_exchange THEN RETURN; END IF; + + -- Check enough funds + IF in_amount IS NOT NULL OR in_suggested_amount IS NOT NULL THEN + SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient( + account_id, + COALESCE(in_amount, in_suggested_amount), + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) AS test INTO out_balance_insufficient, out_bad_amount; + IF out_balance_insufficient OR out_bad_amount THEN + RETURN; + END IF; + END IF; END IF; -- Create withdrawal operation @@ -1138,6 +1314,7 @@ INSERT INTO taler_withdrawal_operations ( amount, suggested_amount, no_amount_to_wallet, + type, creation_date ) VALUES ( in_withdrawal_uuid, @@ -1145,6 +1322,7 @@ INSERT INTO taler_withdrawal_operations ( in_amount, in_suggested_amount, in_no_amount_to_wallet, + 'reserve', in_timestamp ); END $$; @@ -1210,9 +1388,8 @@ IF out_no_op OR out_aborted OR out_already_selected OR out_amount_differs OR sel END IF; -- Check reserve_pub reuse -SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_reserve_pub AND type = 'reserve') OR - EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub) - INTO out_reserve_pub_reuse; +out_reserve_pub_reuse=EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_reserve_pub AND type = 'reserve') OR + EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub AND type = 'reserve'); IF out_reserve_pub_reuse THEN RETURN; END IF; @@ -1246,7 +1423,6 @@ COMMENT ON FUNCTION select_taler_withdrawal IS 'Set details of a withdrawal oper CREATE FUNCTION abort_taler_withdrawal( IN in_withdrawal_uuid uuid, - IN in_username TEXT, OUT out_no_op BOOLEAN, OUT out_already_confirmed BOOLEAN ) @@ -1257,8 +1433,6 @@ UPDATE taler_withdrawal_operations FROM bank_accounts JOIN customers ON owning_customer_id=customer_id WHERE withdrawal_uuid=in_withdrawal_uuid - AND wallet_bank_account=bank_account_id - AND (in_username IS NULL OR username = in_username) RETURNING confirmation_done INTO out_already_confirmed; IF NOT FOUND OR out_already_confirmed THEN @@ -1282,6 +1456,7 @@ CREATE FUNCTION confirm_taler_withdrawal( IN in_amount taler_amount, OUT out_no_op BOOLEAN, OUT out_balance_insufficient BOOLEAN, + OUT out_reserve_pub_reuse BOOLEAN, OUT out_bad_amount BOOLEAN, OUT out_creditor_not_found BOOLEAN, OUT out_not_selected BOOLEAN, @@ -1299,32 +1474,34 @@ DECLARE amount_local taler_amount; exchange_bank_account_id INT8; tx_row_id INT8; + local_type taler_incoming_type; BEGIN +-- Load account info +SELECT bank_account_id, NOT in_is_tan AND cardinality(tan_channels) > 0 +INTO wallet_bank_account_local, out_tan_required +FROM bank_accounts +JOIN customers ON owning_customer_id=customer_id +WHERE username=in_username AND deleted_at IS NULL; + -- Check op exists and conflict SELECT confirmation_done, aborted, NOT selection_done, - reserve_pub, subject, + reserve_pub, subject, type, exchange_bank_account, - wallet_bank_account, (amount).val, (amount).frac, - NOT in_is_tan AND cardinality(tan_channels) > 0, amount IS NULL AND in_amount IS NULL, amount != in_amount INTO already_confirmed, out_aborted, out_not_selected, - reserve_pub_local, subject_local, + reserve_pub_local, subject_local, local_type, exchange_bank_account_id, - wallet_bank_account_local, amount_local.val, amount_local.frac, - out_tan_required, out_missing_amount, out_amount_differs FROM taler_withdrawal_operations - JOIN bank_accounts ON wallet_bank_account=bank_account_id - JOIN customers ON owning_customer_id=customer_id - WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL; + WHERE withdrawal_uuid=in_withdrawal_uuid; out_no_op=NOT FOUND; IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs OR out_tan_required THEN RETURN; @@ -1335,30 +1512,30 @@ END IF; SELECT -- not checking for accounts existence, as it was done above. transfer.out_balance_insufficient, transfer.out_bad_amount, + transfer.out_reserve_pub_reuse out_credit_row_id - INTO out_balance_insufficient, out_bad_amount, tx_row_id -FROM bank_wire_transfer( + INTO out_balance_insufficient, out_bad_amount, out_reserve_pub_reuse, tx_row_id +FROM make_incoming( exchange_bank_account_id, wallet_bank_account_local, subject_local, amount_local, in_timestamp, + local_type, + reserve_pub_local, in_wire_transfer_fees, in_min_amount, in_max_amount ) as transfer; -IF out_balance_insufficient OR out_bad_amount THEN +IF out_balance_insufficient OR out_reserve_pub_reuse OR out_bad_amount THEN RETURN; END IF; -- Confirm operation and update amount UPDATE taler_withdrawal_operations - SET amount=amount_local, confirmation_done = true + SET amount=amount_local, confirmation_done=true WHERE withdrawal_uuid=in_withdrawal_uuid; --- Register incoming transaction -CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, reserve_pub_local, exchange_bank_account_id); - -- Notify status change PERFORM pg_notify('bank_withdrawal_status', in_withdrawal_uuid::text || ' confirmed'); END $$; @@ -1413,19 +1590,21 @@ IF out_too_small THEN RETURN; END IF; --- Perform bank wire transfer +-- Perform incoming transaction SELECT transfer.out_balance_insufficient, transfer.out_credit_row_id INTO out_balance_insufficient, tx_row_id - FROM bank_wire_transfer( + FROM make_incoming( exchange_account_id, admin_account_id, in_subject, converted_amount, in_timestamp, + 'reserve'::taler_incoming_type, + in_reserve_pub, NULL, NULL, NULL @@ -1434,9 +1613,6 @@ IF out_balance_insufficient THEN RETURN; END IF; --- Register incoming transaction -CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, in_reserve_pub, exchange_account_id); - -- update stats CALL stats_register_payment('cashin', NULL, converted_amount, in_amount); @@ -1571,11 +1747,7 @@ INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( ,in_subject ,account_cashout_payto ,in_timestamp - -- use gen_random_uuid to get some randomness - -- remove all - characters as they are not random - -- capitalise the UUID as some bank may still be case sensitive - -- end with 34 random chars which is valid for EBICS (max 35 chars) - ,upper(replace(gen_random_uuid()::text, '-', '')) + ,libeufin_nexus.ebics_id_gen() ); -- update stats @@ -2108,4 +2280,165 @@ LANGUAGE sql AS $$ )) ON CONFLICT (key) DO UPDATE SET value = excluded.value $$; +CREATE FUNCTION register_prepared_transfers ( + IN in_exchange_username TEXT, + IN in_type taler_incoming_type, + IN in_account_pub BYTEA, + IN in_authorization_pub BYTEA, + IN in_recurrent BOOLEAN, + IN in_amount taler_amount, + IN in_timestamp INT8, + IN in_subject TEXT, + -- Error status + OUT out_unknown_account BOOLEAN, + OUT out_not_exchange BOOLEAN, + OUT out_reserve_pub_reuse BOOLEAN, + -- Success status + OUT out_withdrawal_uuid UUID +) +LANGUAGE plpgsql AS $$ +DECLARE + local_withdrawal_id INT8; + exchange_account_id INT8; + talerable_tx INT8; + idempotent BOOLEAN; +BEGIN +-- Retrieve exchange account if +SELECT bank_account_id, NOT is_taler_exchange + INTO exchange_account_id, out_not_exchange + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE username = in_exchange_username; +out_unknown_account=NOT FOUND; +if out_unknown_account OR out_not_exchange THEN RETURN; END IF; + +-- Check idempotency +SELECT withdrawal_uuid, prepared_transfers.type = in_type + AND account_pub = in_account_pub + AND recurrent = in_recurrent + AND amount = in_amount +INTO out_withdrawal_uuid, idempotent +FROM prepared_transfers +LEFT JOIN taler_withdrawal_operations USING (withdrawal_id) +WHERE authorization_pub = in_authorization_pub; + +-- Check idempotency and delay garbage collection +IF FOUND AND idempotent THEN + UPDATE prepared_transfers + SET registered_at=in_timestamp + WHERE authorization_pub=in_authorization_pub; + RETURN; +END IF; + +-- Check reserve pub reuse +out_reserve_pub_reuse=in_type = 'reserve' AND ( + EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_account_pub AND type = 'reserve') +); +IF out_reserve_pub_reuse THEN + RETURN; +END IF; + +-- Create/replace withdrawal +IF out_withdrawal_uuid IS NOT NULL THEN + PERFORM abort_taler_withdrawal(out_withdrawal_uuid); +END IF; +out_withdrawal_uuid=null; + +IF in_recurrent THEN + -- Finalize one pending right now + DELETE FROM pending_recurrent_incoming_transactions + WHERE bank_transaction_id = ( + SELECT bank_transaction_id + FROM pending_recurrent_incoming_transactions + JOIN bank_account_transactions USING (bank_transaction_id) + ORDER BY transaction_date ASC + LIMIT 1 + ) + RETURNING bank_transaction_id + INTO talerable_tx; + IF FOUND THEN + PERFORM register_incoming(talerable_tx, in_type, in_account_pub, exchange_account_id); + END IF; +ELSE + -- Bounce all pending + PERFORM bounce(debtor_account_id, bank_transaction_id, 'cancelled mapping', in_timestamp) + FROM pending_recurrent_incoming_transactions + WHERE authorization_pub = in_authorization_pub; + + -- Create withdrawal + INSERT INTO taler_withdrawal_operations ( + withdrawal_uuid, + wallet_bank_account, + amount, + suggested_amount, + no_amount_to_wallet, + exchange_bank_account, + type, + reserve_pub, + subject, + selection_done, + creation_date + ) VALUES ( + gen_random_uuid(), + NULL, + in_amount, + NULL, + true, + exchange_account_id, + 'map', + in_account_pub, + in_subject, + true, + in_timestamp + ) RETURNING withdrawal_uuid, withdrawal_id + INTO out_withdrawal_uuid, local_withdrawal_id; +END IF; + +-- Upsert registration +INSERT INTO prepared_transfers ( + type, + account_pub, + authorization_pub, + recurrent, + registered_at, + bank_transaction_id, + withdrawal_id +) VALUES ( + in_type, + in_account_pub, + in_authorization_pub, + in_recurrent, + in_timestamp, + talerable_tx, + local_withdrawal_id +) ON CONFLICT (authorization_pub) +DO UPDATE SET + type = EXCLUDED.type, + account_pub = EXCLUDED.account_pub, + recurrent = EXCLUDED.recurrent, + registered_at = EXCLUDED.registered_at, + bank_transaction_id = EXCLUDED.bank_transaction_id, + withdrawal_id = EXCLUDED.withdrawal_id; +END $$; + +CREATE FUNCTION delete_prepared_transfers ( + IN in_authorization_pub BYTEA, + IN in_timestamp INT8, + OUT out_found BOOLEAN +) +LANGUAGE plpgsql AS $$ +BEGIN +-- Bounce all pending +PERFORM bounce(debtor_account_id, bank_transaction_id, 'cancelled mapping', in_timestamp) +FROM pending_recurrent_incoming_transactions +WHERE authorization_pub = in_authorization_pub; + +-- Delete registration +DELETE FROM prepared_transfers +WHERE authorization_pub = in_authorization_pub; +out_found = FOUND; + +-- TODO abort withdrawal +END $$; + COMMIT; \ No newline at end of file diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -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 General Public License as published by the Free Software @@ -51,11 +51,7 @@ LANGUAGE plpgsql AS $$ PERFORM bounce_incoming( NEW.incoming_transaction_id ,((local_amount).val, (local_amount).frac)::taler_amount - -- use gen_random_uuid to get some randomness - -- remove all - characters as they are not random - -- capitalise the UUID as some bank may still be case sensitive - -- end with 34 random chars which is valid for EBICS (max 35 chars) - ,upper(replace(gen_random_uuid()::text, '-', '')) + ,libeufin_nexus.ebics_id_gen() ,now_date ,'amount too small to be converted' ); diff --git a/database-versioning/libeufin-nexus-0014.sql b/database-versioning/libeufin-nexus-0014.sql @@ -18,8 +18,36 @@ BEGIN; SELECT _v.register_patch('libeufin-nexus-0014', NULL, NULL); SET search_path TO libeufin_nexus; +-- Drop unused index +DROP INDEX talerable_incoming_polymorphism; -- Add outgoing transactions metadata field ALTER TABLE transfer_operations ADD COLUMN metadata TEXT; ALTER TABLE talerable_outgoing_transactions ADD COLUMN metadata TEXT; + +-- Replace unused wad type with new mapping type +ALTER TYPE taler_incoming_type RENAME VALUE 'wad' TO 'map'; + +CREATE TABLE prepared_transfers ( + type taler_incoming_type NOT NULL, + account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), + authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32), + recurrent BOOLEAN NOT NULL, + reference_number TEXT UNIQUE NOT NULL CHECK(reference_number ~ '^\d{27}$'), + registered_at INT8 NOT NULL, + incoming_transaction_id INT8 UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX prepared_transfers_unique_reserve_pub + ON prepared_transfers (account_pub) WHERE type = 'reserve'; +CREATE INDEX prepared_transfers_timestamp + ON prepared_transfers (registered_at); + +CREATE TABLE pending_recurrent_incoming_transactions( + incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE, + authorization_pub BYTEA NOT NULL REFERENCES prepared_transfers(authorization_pub) +); +CREATE INDEX pending_recurrent_incoming_transactions_auth_pub + ON pending_recurrent_incoming_transactions (authorization_pub); + + COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -42,6 +42,17 @@ BEGIN END $do$; +CREATE FUNCTION ebics_id_gen() +RETURNS TEXT +LANGUAGE sql AS $$ +-- use gen_random_uuid to get some randomness +-- remove all - characters as they are not random +-- capitalise the UUID as some bank may still be case sensitive +-- end with 34 random chars which is valid for EBICS (max 35 chars) +SELECT upper(replace(gen_random_uuid()::text, '-', '')); +$$; + + CREATE FUNCTION amount_normalize( IN amount taler_amount ,OUT normalized taler_amount @@ -240,12 +251,16 @@ CREATE FUNCTION register_incoming( ,IN in_acct_svcr_ref TEXT ,IN in_type taler_incoming_type ,IN in_metadata BYTEA + ,IN in_qr_reference_number TEXT -- Error status ,OUT out_reserve_pub_reuse BOOLEAN + ,OUT out_mapping_reuse BOOLEAN + ,OUT out_unknown_mapping BOOLEAN -- Success return ,OUT out_found BOOLEAN ,OUT out_completed BOOLEAN ,OUT out_talerable BOOLEAN + ,OUT out_pending BOOLEAN ,OUT out_tx_id INT8 ,OUT out_bounce_id TEXT ) @@ -255,10 +270,12 @@ local_ref TEXT; local_amount taler_amount; local_subject TEXT; local_debit_payto TEXT; +local_mapped_by BYTEA; BEGIN IF in_credit_fee = (0, 0)::taler_amount THEN in_credit_fee = NULL; END IF; +out_pending=FALSE; -- Check if already registered SELECT incoming_transaction_id, tx.subject, debit_payto, (tx.amount).val, (tx.amount).frac, metadata IS NOT NULL, end_to_end_id @@ -269,6 +286,27 @@ SELECT incoming_transaction_id, tx.subject, debit_payto, (tx.amount).val, (tx.am LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) WHERE uetr = in_uetr OR tx_id = in_tx_id OR acct_svcr_ref = in_acct_svcr_ref; out_found=FOUND; + +IF NOT out_found OR NOT out_talerable THEN + -- Resolve mapping logic + IF in_type = 'map' OR in_qr_reference_number IS NOT NULL THEN + SELECT type, account_pub, authorization_pub, incoming_transaction_id IS NOT NULL AND NOT recurrent, incoming_transaction_id IS NOT NULL AND recurrent + INTO in_type, in_metadata, local_mapped_by, out_mapping_reuse, out_pending + FROM prepared_transfers + WHERE authorization_pub = in_metadata OR reference_number = in_qr_reference_number; + out_unknown_mapping = NOT FOUND; + IF out_unknown_mapping OR out_mapping_reuse THEN + RETURN; + END IF; + END IF; + + -- Check reserve pub reuse + out_reserve_pub_reuse=NOT out_pending AND in_type = 'reserve' AND EXISTS(SELECT FROM talerable_incoming_transactions WHERE metadata = in_metadata AND type = 'reserve'); + IF out_reserve_pub_reuse THEN + RETURN; + END IF; +END IF; + IF out_found THEN local_ref=COALESCE(in_uetr::text, in_tx_id, in_acct_svcr_ref); -- Check metadata @@ -293,10 +331,6 @@ IF out_found THEN PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text); END IF; ELSE - out_reserve_pub_reuse=in_type = 'reserve' AND EXISTS(SELECT FROM talerable_incoming_transactions WHERE metadata = in_metadata AND type = 'reserve'); - IF out_reserve_pub_reuse THEN - RETURN; - END IF; -- Store the transaction in the database INSERT INTO incoming_transactions ( amount @@ -325,18 +359,29 @@ END IF; -- Register as talerable if not already registered as such and not already bounced IF in_type IS NOT NULL AND NOT out_talerable AND out_bounce_id IS NULL THEN - -- We cannot use ON CONFLICT here because conversion use a trigger before insertion that isn't idempotent - INSERT INTO talerable_incoming_transactions ( - incoming_transaction_id - ,type - ,metadata - ) VALUES ( - out_tx_id - ,in_type - ,in_metadata - ); - PERFORM pg_notify('nexus_incoming_tx', out_tx_id::text); - out_talerable=TRUE; + If out_pending THEN + -- Delay talerable registration until mapping again + INSERT INTO pending_recurrent_incoming_transactions (incoming_transaction_id, authorization_pub) + VALUES (out_tx_id, local_mapped_by); + ELSE + IF local_mapped_by IS NOT NULL THEN + UPDATE prepared_transfers + SET incoming_transaction_id = out_tx_id + WHERE authorization_pub = local_mapped_by; + END IF; + -- We cannot use ON CONFLICT here because conversion use a trigger before insertion that isn't idempotent + INSERT INTO talerable_incoming_transactions ( + incoming_transaction_id + ,type + ,metadata + ) VALUES ( + out_tx_id + ,in_type + ,in_metadata + ); + PERFORM pg_notify('nexus_incoming_tx', out_tx_id::text); + out_talerable=TRUE; + END IF; END IF; END $$; @@ -368,8 +413,8 @@ bounce_amount taler_amount; BEGIN -- Register incoming transaction SELECT reg.out_found, reg.out_completed, reg.out_tx_id, reg.out_talerable - FROM register_incoming(in_amount, in_credit_fee, in_subject, in_execution_time, in_debit_payto, in_uetr, in_tx_id, in_acct_svcr_ref, NULL, NULL) as reg - INTO out_found, out_completed, out_tx_id, out_talerable; + INTO out_found, out_completed, out_tx_id, out_talerable + FROM register_incoming(in_amount, in_credit_fee, in_subject, in_execution_time, in_debit_payto, in_uetr, in_tx_id, in_acct_svcr_ref, NULL, NULL, NULL) as reg; -- Cannot bounce a transaction registered as talerable IF out_talerable THEN RETURN; @@ -425,6 +470,9 @@ IF NOT FOUND THEN INSERT INTO libeufin_nexus.bounced_transactions (incoming_transaction_id, initiated_outgoing_transaction_id) VALUES (in_tx_id, init_id); END IF; + +-- Delete from pending if any +DELETE FROM libeufin_nexus.pending_recurrent_incoming_transactions WHERE incoming_transaction_id = in_tx_id; END$$; CREATE FUNCTION taler_transfer( @@ -606,4 +654,124 @@ BEGIN WHERE message_id = in_message_id AND status NOT IN ('success', 'permanent_failure', 'late_failure'); END IF; +END $$; + +CREATE FUNCTION register_prepared_transfers ( + IN in_type taler_incoming_type, + IN in_account_pub BYTEA, + IN in_authorization_pub BYTEA, + IN in_recurrent BOOLEAN, + IN in_reference_number TEXT, + IN in_timestamp INT8, + -- Error status + OUT out_subject_reuse BOOLEAN, + OUT out_reserve_pub_reuse BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE + talerable_tx INT8; + idempotent BOOLEAN; +BEGIN + +-- Check idempotency +SELECT type = in_type + AND account_pub = in_account_pub + AND recurrent = in_recurrent + AND reference_number = in_reference_number +INTO idempotent +FROM prepared_transfers +WHERE authorization_pub = in_authorization_pub; + +-- Check idempotency and delay garbage collection +IF FOUND AND idempotent THEN + UPDATE prepared_transfers + SET registered_at=in_timestamp + WHERE authorization_pub=in_authorization_pub; + RETURN; +END IF; + +-- Check reserve pub reuse and reference_number clash +out_reserve_pub_reuse=in_type = 'reserve' AND ( + EXISTS(SELECT FROM talerable_incoming_transactions WHERE metadata = in_account_pub AND type = 'reserve') + OR EXISTS(SELECT FROM prepared_transfers WHERE account_pub = in_account_pub AND type = 'reserve' AND authorization_pub != in_authorization_pub) +); +out_subject_reuse=EXISTS(SELECT FROM prepared_transfers WHERE authorization_pub != in_authorization_pub AND reference_number = in_reference_number); +IF out_reserve_pub_reuse OR out_subject_reuse THEN + RETURN; +END IF; + +IF in_recurrent THEN + -- Finalize one pending right now + WITH moved_tx AS ( + DELETE FROM pending_recurrent_incoming_transactions + WHERE incoming_transaction_id = ( + SELECT incoming_transaction_id + FROM pending_recurrent_incoming_transactions + JOIN incoming_transactions USING (incoming_transaction_id) + ORDER BY execution_time ASC + LIMIT 1 + ) + RETURNING incoming_transaction_id + ) + INSERT INTO talerable_incoming_transactions (incoming_transaction_id, type, metadata) + SELECT incoming_transaction_id, in_type, in_account_pub + FROM moved_tx + RETURNING incoming_transaction_id INTO talerable_tx; + IF talerable_tx IS NOT NULL THEN + PERFORM pg_notify('nexus_incoming_tx', talerable_tx::text); + END IF; +ELSE + -- Bounce all pending + PERFORM bounce_incoming(incoming_transaction_id, amount, ebics_id_gen(), in_timestamp, 'cancelled mapping') + FROM incoming_transactions + JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + WHERE authorization_pub = in_authorization_pub; +END IF; + +-- Upsert registration +INSERT INTO prepared_transfers ( + type, + account_pub, + authorization_pub, + recurrent, + reference_number, + registered_at, + incoming_transaction_id +) VALUES ( + in_type, + in_account_pub, + in_authorization_pub, + in_recurrent, + in_reference_number, + in_timestamp, + talerable_tx +) ON CONFLICT (authorization_pub) +DO UPDATE SET + type = EXCLUDED.type, + account_pub = EXCLUDED.account_pub, + recurrent = EXCLUDED.recurrent, + reference_number = EXCLUDED.reference_number, + registered_at = EXCLUDED.registered_at, + incoming_transaction_id = EXCLUDED.incoming_transaction_id; +END $$; + +CREATE FUNCTION delete_prepared_transfers ( + IN in_authorization_pub BYTEA, + IN in_timestamp INT8, + OUT out_found BOOLEAN +) +LANGUAGE plpgsql AS $$ +BEGIN + +-- Bounce all pending +PERFORM bounce_incoming(incoming_transaction_id, amount, ebics_id_gen(), in_timestamp, 'cancelled mapping') +FROM incoming_transactions +JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) +WHERE authorization_pub = in_authorization_pub; + +-- Delete registration +DELETE FROM prepared_transfers +WHERE authorization_pub = in_authorization_pub; +out_found = FOUND; + END $$; \ No newline at end of file diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -39,6 +39,7 @@ fun Application.corebankWebApp(db: Database, cfg: BankConfig) = talerApi(LoggerF conversionApi(db, cfg) bankIntegrationApi(db, cfg) wireGatewayApi(db, cfg) + wireTransferApi(db, cfg) revenueApi(db, cfg) observabilityApi(db, cfg) cfg.spaPath?.let { diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -84,8 +84,8 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { "Cannot select different exchange and reserve pub. under the same withdrawal operation", TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT ) - WithdrawalSelectionResult.RequestPubReuse -> throw conflict( - "Reserve pub. already used", + WithdrawalSelectionResult.ReservePubReuse -> throw conflict( + "Reserve pub already used", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) WithdrawalSelectionResult.UnknownAccount -> throw conflict( @@ -120,7 +120,7 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { } post("/taler-integration/withdrawal-operation/{wopid}/abort") { val uuid = call.uuidPath("wopid") - when (db.withdrawal.abort(uuid, null)) { + when (db.withdrawal.abort(uuid)) { AbortResult.UnknownOperation -> throw notFound( "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -608,6 +608,10 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { "Cannot confirm an aborted withdrawal", TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT ) + WithdrawalConfirmationResult.ReservePubReuse -> throw conflict( + "Reserve pub already used", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) WithdrawalConfirmationResult.NotSelected -> throw conflict( "Cannot confirm an unselected withdrawal", TalerErrorCode.BANK_CONFIRM_INCOMPLETE @@ -636,7 +640,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) { } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { val opId = call.uuidPath("withdrawal_id") - when (db.withdrawal.abort(opId, call.pathUsername)) { + when (db.withdrawal.abort(opId)) { AbortResult.UnknownOperation -> throw notFound( "Withdrawal operation $opId not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -26,7 +26,6 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.util.pipeline.* import tech.libeufin.bank.* import tech.libeufin.bank.auth.auth import tech.libeufin.bank.auth.authAdmin @@ -41,10 +40,12 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { get("/accounts/{USERNAME}/taler-wire-gateway/config") { - call.respond(WireGatewayConfig( - currency = cfg.regionalCurrency, - support_account_check = false - )) + call.respond( + WireGatewayConfig( + currency = cfg.regionalCurrency, + support_account_check = false, + ) + ) } auth(db, cfg.pwCrypto, TokenLogicalScope.readwrite_wiregateway, cfg.basicAuthCompat) { post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { @@ -63,22 +64,27 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { "Wire transfer attempted with credit and debit party being both exchange account", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) + TransferResult.ReserveUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) + TransferResult.WtidReuse -> throw conflict( "wtid used already", TalerErrorCode.BANK_TRANSFER_WTID_REUSED ) + TransferResult.BalanceInsufficient -> throw conflict( "Insufficient balance for exchange", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + TransferResult.AdminCreditor -> throw conflict( "Cannot transfer money to admin account when conversion is disabled", TalerErrorCode.BANK_ADMIN_CREDITOR ) + is TransferResult.Success -> call.respond( TransferResponse( timestamp = res.timestamp, @@ -90,12 +96,12 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { } auth(db, cfg.pwCrypto, TokenLogicalScope.readonly_wiregateway, cfg.basicAuthCompat) { suspend fun <T> ApplicationCall.historyEndpoint( - reduce: (List<T>, String) -> Any, + reduce: (List<T>, String) -> Any, dbLambda: suspend ExchangeDAO.(HistoryParams, Long) -> List<T> ) { val params = HistoryParams.extract(this.request.queryParameters) val bankAccount = this.bankInfo(db) - + if (!bankAccount.isTalerExchange) throw notExchange(pathUsername) @@ -118,7 +124,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw notExchange(call.pathUsername) - + if (params.status != null && params.status != TransferStatusState.success && params.status != TransferStatusState.permanent_failure) { call.respond(HttpStatusCode.NoContent) } else { @@ -135,7 +141,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw notExchange(call.pathUsername) - + val txId = call.longPath("ROW_ID") val transfer = db.exchange.getTransfer(bankAccount.bankAccountId, txId) ?: throw notFound( "Transfer '$txId' not found", @@ -147,10 +153,10 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw notExchange(call.pathUsername) - + val params = AccountCheckParams.extract(call.request.queryParameters) val account = params.account.expectIban() - + val info = db.account.checkInfo(account) ?: throw unknownAccount(account.canonical) call.respond(info) } @@ -179,18 +185,24 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { "Debtor account $debitAccount was not found", TalerErrorCode.BANK_UNKNOWN_DEBTOR ) + AddIncomingResult.BothPartyAreExchange -> throw conflict( "Wire transfer attempted with credit and debit party being both exchange account", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) + AddIncomingResult.ReservePubReuse -> throw conflict( "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) + AddIncomingResult.BalanceInsufficient -> throw conflict( "Insufficient balance for debitor", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + + AddIncomingResult.UnknownMapping, AddIncomingResult.MappingReuse -> throw UnsupportedOperationException("no mapping used") + is AddIncomingResult.Success -> this.respond( AddIncomingResponse( timestamp = TalerTimestamp(timestamp), @@ -218,4 +230,4 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { ) } } -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt @@ -0,0 +1,113 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.pipeline.* +import tech.libeufin.bank.* +import tech.libeufin.bank.auth.pathUsername +import tech.libeufin.bank.db.Database +import tech.libeufin.bank.db.TransferDAO +import tech.libeufin.bank.db.TransferDAO.RegistrationResult +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import java.time.Instant +import java.time.Duration + +fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { + get("/accounts/{USERNAME}/taler-wire-transfer/config") { + call.respond( + WireTransferConfig( + currency = cfg.regionalCurrency, + supported_formats = listOf(SubjectFormat.SIMPLE) + ) + ) + } + post("/accounts/{USERNAME}/taler-wire-transfer/registration") { + val username = call.pathUsername + val req = call.receive<SubjectRequest>(); + cfg.checkRegionalCurrency(req.credit_amount) + + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_signature, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + when (val result = db.transfer.register( + username, + req.type, + req.account_pub, + req.authorization_pub, + req.recurrent, + req.credit_amount, + Instant.now() + )) { + RegistrationResult.UnknownAccount -> throw unknownAccount(username) + RegistrationResult.NotExchange -> throw notExchange(username) + RegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + is RegistrationResult.Success -> { + val subjects = mutableListOf<TransferSubject>() + if (result.uuid != null) + subjects.add(TransferSubject.Uri(cfg.talerWithdrawUri(result.uuid))) + subjects.add(TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount)) + call.respond( + SubjectResult( + subjects, + TalerTimestamp.never() + ) + ) + } + } + } + delete("/accounts/{USERNAME}/taler-wire-transfer/registration") { + val req = call.receive<Unregistration>(); + + val timestamp = Instant.parse(req.timestamp) + + if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + throw conflict( + "timestamp too old", + TalerErrorCode.BANK_OLD_TIMESTAMP + ) + + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_signature, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { + call.respond(HttpStatusCode.NoContent) + } else { + throw notFound( + "Prepared transfer '${req.authorization_pub}' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + } + } +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2025, 2024, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -174,7 +174,7 @@ class AccountDAO(private val db: Database) { if (bonus.value != 0L || bonus.frac != 0) { conn.withStatement(""" SELECT out_balance_insufficient - FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,NULL,NULL,NULL) + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,NULL,NULL,NULL, NULL, NULL, NULL) """) { bind(internalPayto.canonical) bind(bonus) diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -46,6 +46,7 @@ class Database( val token = TokenDAO(this) val tan = TanDAO(this) val gc = GcDAO(this) + val transfer = TransferDAO(this) // Transaction flows, the keys are the bank account id private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow<Long>>() diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -61,7 +61,7 @@ class ExchangeDAO(private val db: Database) { debit_account = it.getBankPayto("debtor_payto", "debtor_name", db.ctx), account_pub = EddsaPublicKey(it.getBytes("metadata")), ) - IncomingType.wad -> throw UnsupportedOperationException() + IncomingType.map -> throw UnsupportedOperationException() } } @@ -246,12 +246,14 @@ class ExchangeDAO(private val db: Database) { /** Result of taler add incoming transaction creation */ sealed interface AddIncomingResult { /** Transaction [id] and wire transfer [timestamp] */ - data class Success(val id: Long, val timestamp: TalerTimestamp): AddIncomingResult + data class Success(val id: Long, val timestamp: TalerTimestamp, val pending: Boolean): AddIncomingResult data object NotAnExchange: AddIncomingResult data object UnknownExchange: AddIncomingResult data object UnknownDebtor: AddIncomingResult data object BothPartyAreExchange: AddIncomingResult data object ReservePubReuse: AddIncomingResult + data object UnknownMapping: AddIncomingResult + data object MappingReuse: AddIncomingResult data object BalanceInsufficient: AddIncomingResult } @@ -271,8 +273,11 @@ class ExchangeDAO(private val db: Database) { ,out_debtor_not_found ,out_both_exchanges ,out_reserve_pub_reuse + ,out_mapping_reuse + ,out_unknown_mapping ,out_debitor_balance_insufficient ,out_tx_row_id + ,out_pending FROM taler_add_incoming ( ?, ?, (?,?)::taler_amount, @@ -296,9 +301,12 @@ class ExchangeDAO(private val db: Database) { it.getBoolean("out_both_exchanges") -> AddIncomingResult.BothPartyAreExchange it.getBoolean("out_debitor_balance_insufficient") -> AddIncomingResult.BalanceInsufficient it.getBoolean("out_reserve_pub_reuse") -> AddIncomingResult.ReservePubReuse + it.getBoolean("out_mapping_reuse") -> AddIncomingResult.MappingReuse + it.getBoolean("out_unknown_mapping") -> AddIncomingResult.UnknownMapping else -> AddIncomingResult.Success( id = it.getLong("out_tx_row_id"), - timestamp = TalerTimestamp(timestamp) + timestamp = TalerTimestamp(timestamp), + pending = it.getBoolean("out_pending") ) } } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -39,7 +39,11 @@ class GcDAO(private val db: Database) { // Abort pending operations conn.withStatement( - "UPDATE taler_withdrawal_operations SET aborted = true WHERE creation_date < ?" + """ + UPDATE taler_withdrawal_operations SET aborted = true WHERE creation_date < ? AND NOT EXISTS( + SELECT FROM prepared_transfers WHERE prepared_transfers.withdrawal_id=taler_withdrawal_operations.withdrawal_id + ) + """ ) { bind(abortAfterMicro) executeUpdate() @@ -68,7 +72,7 @@ class GcDAO(private val db: Database) { // Hard delete soft deleted customer without bank transactions, bank account are deleted by CASCADE conn.withStatement(""" DELETE FROM customers WHERE deleted_at IS NOT NULL AND NOT EXISTS( - SELECT 1 FROM bank_account_transactions NATURAL JOIN bank_accounts + SELECT FROM bank_account_transactions NATURAL JOIN bank_accounts WHERE owning_customer_id=customer_id ) """) { diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -56,9 +56,18 @@ class TransactionDAO(private val db: Database) { wireTransferFees: TalerAmount, minAmount: TalerAmount, maxAmount: TalerAmount - ): BankTransactionResult = db.serializableTransaction { conn -> - val timestamp = timestamp.micros() - conn.withStatement(""" + ): BankTransactionResult { + val (type: IncomingType?, metadata: EddsaPublicKey?, bounceCause: String?) = runCatching { parseIncomingSubject(subject) }.fold( + onSuccess = { metadata -> + if (metadata is IncomingSubject.AdminBalanceAdjust) { + Triple(null, null, "unsupported admin balance adjust") + } else { + Triple(metadata.type, metadata.key, null) + } + }, + onFailure = { e -> Triple(null, null, "malformed metadata - ${e.message}") } + ) + return db.serializable(""" SELECT out_creditor_not_found ,out_debtor_not_found @@ -75,7 +84,7 @@ class TransactionDAO(private val db: Database) { ,out_debtor_is_exchange ,out_creditor_admin ,out_idempotent - FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount) + FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount,?::taler_incoming_type,?,?) """ ) { bind(creditAccountPayto.canonical) @@ -88,6 +97,9 @@ class TransactionDAO(private val db: Database) { bind(wireTransferFees) bind(minAmount) bind(maxAmount) + bind(type) + bind(metadata) + bind(bounceCause) one { when { it.getBoolean("out_creditor_not_found") -> BankTransactionResult.UnknownCreditor @@ -99,61 +111,7 @@ class TransactionDAO(private val db: Database) { it.getBoolean("out_request_uid_reuse") -> BankTransactionResult.RequestUidReuse it.getBoolean("out_idempotent") -> BankTransactionResult.Success(it.getLong("out_debit_row_id")) it.getBoolean("out_tan_required") -> BankTransactionResult.TanRequired - else -> { - val creditAccountId = it.getLong("out_credit_bank_account_id") - val creditRowId = it.getLong("out_credit_row_id") - val debitAccountId = it.getLong("out_debit_bank_account_id") - val debitRowId = it.getLong("out_debit_row_id") - val exchangeCreditor = it.getBoolean("out_creditor_is_exchange") - val exchangeDebtor = it.getBoolean("out_debtor_is_exchange") - if (exchangeCreditor && exchangeDebtor) { - logger.warn("exchange account $exchangeDebtor sent a manual transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop, may fail in the future") - } else if (exchangeCreditor) { - val bounceCause = runCatching { parseIncomingSubject(subject) }.fold( - onSuccess = { metadata -> - if (metadata is IncomingSubject.AdminBalanceAdjust) { - "unsupported admin balance adjust" - } else { - val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { - bind(creditRowId) - bind(metadata.type) - bind(metadata.key) - bind(creditAccountId) - executeProcedureViolation() - } - if (!registered) { - logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key") - "reserve public key reuse" - } else { - null - } - } - }, - onFailure = { e -> - logger.warn("exchange account $creditAccountId received a manual transaction $creditRowId with malformed metadata: ${e.message}") - "malformed metadata: ${e.message}" - } - ) - if (bounceCause != null) { - // No error can happens because an opposite transaction already took place in the same transaction - conn.withStatement(""" - SELECT bank_wire_transfer( - ?, ?, ?, (?, ?)::taler_amount, ?, NULL,NULL,NULL - ); - """) { - bind(debitAccountId) - bind(creditAccountId) - bind("Bounce $creditRowId: $bounceCause") - bind(amount) - bind(timestamp) - executeQuery() - } - } - } else if (exchangeDebtor) { - logger.warn("exchange account $debitAccountId sent a manual transaction $debitRowId which will not be recorderd as a taler outgoing transaction, use the API instead") - } - BankTransactionResult.Success(debitRowId) - } + else -> BankTransactionResult.Success(it.getLong("out_debit_row_id")) } } } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransferDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransferDAO.kt @@ -0,0 +1,86 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank.db + +import tech.libeufin.common.* +import tech.libeufin.common.db.* +import java.time.Instant +import java.util.UUID + +/** Data access logic for transfer specific logic */ +class TransferDAO(private val db: Database) { + /** Result of prepared transfer registration */ + sealed interface RegistrationResult { + data class Success(val uuid: UUID?): RegistrationResult + data object UnknownAccount: RegistrationResult + data object NotExchange: RegistrationResult + data object ReservePubReuse: RegistrationResult + } + + /** Register a prepared transfer */ + suspend fun register( + exchangeUsername: String, + type: TransferType, + accountPub: EddsaPublicKey, + authPub: EddsaPublicKey, + recurrent: Boolean, + amount: TalerAmount, + timestamp: Instant + ): RegistrationResult = db.serializable( + """ + SELECT out_unknown_account, out_not_exchange, out_reserve_pub_reuse, out_withdrawal_uuid + FROM register_prepared_transfers ( + ?, ?::taler_incoming_type, ?, ?, ?, (?, ?)::taler_amount, ?, ? + ) + """ + ) { + bind(exchangeUsername) + bind(type) + bind(accountPub) + bind(authPub) + bind(recurrent) + bind(amount) + bind(timestamp) + bind("Taler prepared MAP:$authPub") + one { + when { + it.getBoolean("out_unknown_account") -> RegistrationResult.UnknownAccount + it.getBoolean("out_not_exchange") -> RegistrationResult.NotExchange + it.getBoolean("out_reserve_pub_reuse") -> RegistrationResult.ReservePubReuse + else -> RegistrationResult.Success(it.getOptObject("out_withdrawal_uuid")) + } + } + } + + /** Unregister a prepared transfer */ + suspend fun unregister( + authorizationPub: EddsaPublicKey, + timestamp: Instant + ) = db.serializable( + "SELECT out_found FROM delete_prepared_transfers(?,?)", + { + bind(authorizationPub) + bind(timestamp) + one { + it.getBoolean(1) + } + } + ) +} +\ No newline at end of file diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -92,18 +92,16 @@ class WithdrawalDAO(private val db: Database) { /** Abort withdrawal operation [uuid] */ suspend fun abort( - uuid: UUID, - username: String?, + uuid: UUID ): AbortResult = db.serializable( """ SELECT out_no_op, out_already_confirmed - FROM abort_taler_withdrawal(?, ?) + FROM abort_taler_withdrawal(?) """ ) { bind(uuid) - bind(username) one { when { it.getBoolean("out_no_op") -> AbortResult.UnknownOperation @@ -118,7 +116,7 @@ class WithdrawalDAO(private val db: Database) { data class Success(val status: WithdrawalStatus): WithdrawalSelectionResult data object UnknownOperation: WithdrawalSelectionResult data object AlreadySelected: WithdrawalSelectionResult - data object RequestPubReuse: WithdrawalSelectionResult + data object ReservePubReuse: WithdrawalSelectionResult data object UnknownAccount: WithdrawalSelectionResult data object AccountIsNotExchange: WithdrawalSelectionResult data object AmountDiffers: WithdrawalSelectionResult @@ -172,7 +170,7 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected it.getBoolean("out_amount_differs") -> WithdrawalSelectionResult.AmountDiffers - it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse + it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.ReservePubReuse it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange else -> WithdrawalSelectionResult.Success(it.getEnum("out_status")) @@ -190,7 +188,8 @@ class WithdrawalDAO(private val db: Database) { AlreadyAborted, TanRequired, MissingAmount, - AmountDiffers + AmountDiffers, + ReservePubReuse } /** Confirm withdrawal operation [uuid] */ @@ -213,7 +212,8 @@ class WithdrawalDAO(private val db: Database) { out_aborted, out_tan_required, out_missing_amount, - out_amount_differs + out_amount_differs, + out_reserve_pub_reuse FROM confirm_taler_withdrawal( ?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount, ${optAmount(amount)} ); @@ -237,6 +237,7 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired it.getBoolean("out_missing_amount") -> WithdrawalConfirmationResult.MissingAmount it.getBoolean("out_amount_differs") -> WithdrawalConfirmationResult.AmountDiffers + it.getBoolean("out_reserve_pub_reuse") -> WithdrawalConfirmationResult.ReservePubReuse else -> WithdrawalConfirmationResult.Success } } diff --git a/libeufin-bank/src/test/kotlin/BankIntegrationApiTest.kt b/libeufin-bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -85,7 +85,7 @@ class BankIntegrationApiTest { // POST /taler-integration/withdrawal-operation/UUID @Test fun select() = bankSetup { - val reserve_pub = EddsaPublicKey.rand() + val reserve_pub = EddsaPublicKey.randEdsaKey() val req = obj { "reserve_pub" to reserve_pub "selected_exchange" to exchangePayto.canonical @@ -123,7 +123,7 @@ class BankIntegrationApiTest { // Check already selected client.post("/taler-integration/withdrawal-operation/$uuid") { json(req) { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() } }.assertConflict(TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT) } @@ -148,7 +148,7 @@ class BankIntegrationApiTest { // Check unknown account client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) @@ -156,14 +156,14 @@ class BankIntegrationApiTest { // Check account not exchange client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to merchantPayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical "amount" to "KUDOS:1" } @@ -180,7 +180,7 @@ class BankIntegrationApiTest { // Check error client.postA("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical } }.assertConflict(TalerErrorCode.BANK_UPDATE_ABORT_CONFLICT) @@ -194,7 +194,7 @@ class BankIntegrationApiTest { // Check insufficient fund client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical "amount" to "KUDOS:11" } @@ -202,7 +202,7 @@ class BankIntegrationApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical "amount" to "KUDOS:1.1" } @@ -224,7 +224,7 @@ class BankIntegrationApiTest { for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical "amount" to amount } @@ -234,7 +234,7 @@ class BankIntegrationApiTest { // Check OK client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto.canonical "amount" to "KUDOS:9" } diff --git a/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt @@ -1450,8 +1450,8 @@ class CoreBankTransactionsApiTest { tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction tx("merchant", "KUDOS:1", "exchange", "ADMIN BALANCE ADJUST") // Bounce admin balance adjust val reservePub = EddsaPublicKey.randEdsaKey() - tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(reservePub)) // Accept incoming - tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(reservePub)) // Bounce reserve_pub reuse + tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(IncomingType.reserve, reservePub)) // Accept incoming + tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(IncomingType.reserve, reservePub)) // Bounce reserve_pub reuse assertBalance("merchant", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") @@ -1696,18 +1696,6 @@ class CoreBankWithdrawalApiTest { .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT) } - // Check confirm another user's operation - client.postA("/accounts/customer/withdrawals") { - json { "amount" to "KUDOS:1" } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.withdrawal_id - withdrawalSelect(uuid) - - // Check error - client.postA("/accounts/merchant/withdrawals/$uuid/abort") - .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } - // Check bad UUID client.postA("/accounts/merchant/withdrawals/chocolate/abort") .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) @@ -1794,6 +1782,18 @@ class CoreBankWithdrawalApiTest { .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) } + // Check reserve pub reuse + client.postA("/accounts/merchant/withdrawals") { + json { "amount" to "KUDOS:5" } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.withdrawal_id + val reservePub = withdrawalSelect(uuid) + + tx("customer", "KUDOS:5", "exchange", "Taler $reservePub") + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") + .assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + } + // Check balance insufficient client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:5" } @@ -1810,18 +1810,6 @@ class CoreBankWithdrawalApiTest { client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() } - // Check confirm another user's operation - client.postA("/accounts/customer/withdrawals") { - json { "amount" to "KUDOS:1" } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.withdrawal_id - withdrawalSelect(uuid) - - // Check error - client.postA("/accounts/merchant/withdrawals/$uuid/confirm") - .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } - // Check bad UUID client.postA("/accounts/merchant/withdrawals/chocolate/confirm") .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) diff --git a/libeufin-bank/src/test/kotlin/GcTest.kt b/libeufin-bank/src/test/kotlin/GcTest.kt @@ -106,7 +106,7 @@ class GcTest { WithdrawalCreationResult.Success ) assertIs<WithdrawalSelectionResult.Success>( - db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, ZERO, ZERO, MAX) + db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.randEdsaKey(), null, ZERO, ZERO, MAX) ) assertEquals( db.withdrawal.confirm(account, uuid, time, null, false, ZERO, ZERO, MAX), @@ -134,6 +134,7 @@ class GcTest { ) ) } + assertNbTx(38) assertNbCashout(5) assertNbBankTx(5) diff --git a/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt @@ -391,11 +391,11 @@ class WireGatewayApiTest { val (path, key) = when (type) { IncomingType.reserve -> Pair("add-incoming", "reserve_pub") IncomingType.kyc -> Pair("add-kycauth", "account_pub") - IncomingType.wad -> throw UnsupportedOperationException() + IncomingType.map -> throw UnsupportedOperationException() } val valid_req = obj { "amount" to "KUDOS:44" - key to EddsaPublicKey.rand() + key to EddsaPublicKey.randEdsaKey() "debit_account" to merchantPayto.canonical } @@ -432,7 +432,7 @@ class WireGatewayApiTest { // Unknown account client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - key to EddsaPublicKey.rand() + key to EddsaPublicKey.randEdsaKey() "debit_account" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR) @@ -440,7 +440,7 @@ class WireGatewayApiTest { // Same account client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - key to EddsaPublicKey.rand() + key to EddsaPublicKey.randEdsaKey() "debit_account" to exchangePayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) diff --git a/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt b/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt @@ -0,0 +1,294 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.test.* +import java.time.Instant +import java.util.UUID +import kotlin.test.* + +class WireTransferApiTest { + // GET /accounts/{USERNAME}/taler-wire-transfer/config + @Test + fun config() = bankSetup { + client.get("/accounts/merchant/taler-wire-transfer/config").assertOkJson<WireTransferConfig>() + } + + // POST /accounts/{USERNAME}/taler-wire-transfer/registration + @Test + fun registration() = bankSetup { + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + val amount = TalerAmount("KUDOS:1") + val valid_req = obj { + "credit_amount" to amount + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + + val simpleSubject = TransferSubject.Simple("Taler MAP:$pub", amount) + + // Valid + val subjects = client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects[1], simpleSubject) + assertIs<TransferSubject.Uri>(it.subjects[0]) + }.subjects + + // Idempotent + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, subjects) + } + + // KYC has a different withdrawal uri + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "kyc" + } + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects[1], simpleSubject) + val uriSubject = assertIs<TransferSubject.Uri>(it.subjects[0]) + assertNotEquals(subjects[0], uriSubject) + } + + // Recurrent only has simple subject + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "recurrent" to true + } + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, listOf(simpleSubject)) + } + + // Bad signature + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "authorization_signature" to EddsaSignature.rand() + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Not exchange + client.post("/accounts/merchant/taler-wire-transfer/registration") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) + + // Unknown account + client.post("/accounts/unknown/taler-wire-transfer/registration") { + json(valid_req) + }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) + + assertBalance("customer", "+KUDOS:0") + assertBalance("exchange", "+KUDOS:0") + + // Non recurrent accept on then bounce + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "reserve" + } + }.assertOkJson<SubjectResult> { + val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') + client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + assertBalance("customer", "-KUDOS:1") + assertBalance("exchange", "+KUDOS:1") + } + + // Withdrawal is aborted on completion + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "kyc" + } + }.assertOkJson<SubjectResult> { + val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') + println("UUID $uuid") + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // kyc + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + client.postA("/accounts/customer/withdrawals/$uuid/confirm") + .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) // aborted + assertBalance("customer", "-KUDOS:2") + assertBalance("exchange", "+KUDOS:2") + } + + // Recurrent accept one and delay others + val newKey = EddsaPublicKey.randEdsaKey() + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "account_pub" to newKey + "authorization_signature" to CryptoUtil.eddsaSign(newKey.raw, priv) + "recurrent" to true + } + } + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + assertBalance("customer", "-KUDOS:7") + assertBalance("exchange", "+KUDOS:7") + + // Complete pending on recurrent update + val kycKey = EddsaPublicKey.randEdsaKey() + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "kyc" + "account_pub" to kycKey + "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "reserve" + "account_pub" to kycKey + "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + assertBalance("customer", "-KUDOS:7") + assertBalance("exchange", "+KUDOS:7") + + // Kyc key reuse keep pending ones + tx("customer", "KUDOS:1", "exchange", "Taler KYC:$kycKey") + assertBalance("customer", "-KUDOS:8") + assertBalance("exchange", "+KUDOS:8") + + // Switching to non recurrent cancel pending + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "type" to "kyc" + "account_pub" to kycKey + "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + } + }.assertOkJson<SubjectResult>() + assertBalance("customer", "-KUDOS:6") + assertBalance("exchange", "+KUDOS:6") + } + + // DELETE /accounts/{USERNAME}/taler-wire-transfer/registration + @Test + fun unregister() = bankSetup { + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + val valid_req = obj { + "credit_amount" to "KUDOS:1" + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + + // Unknown + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Know + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult>() + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNoContent() + + // Idempotent + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Bad signature + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Old timestamp + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().minusSeconds(1000000).toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) + + // Unknown bounce + assertBalance("customer", "+KUDOS:0") + assertBalance("exchange", "+KUDOS:0") + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + assertBalance("customer", "+KUDOS:0") + assertBalance("exchange", "+KUDOS:0") + + // Pending bounced after deletion + val newKey = EddsaPublicKey.randEdsaKey() + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) { + "account_pub" to newKey + "authorization_signature" to CryptoUtil.eddsaSign(newKey.raw, priv) + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending + assertBalance("customer", "-KUDOS:3") + assertBalance("exchange", "+KUDOS:3") + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNoContent() + assertBalance("customer", "-KUDOS:1") + assertBalance("exchange", "+KUDOS:1") + } +} +\ No newline at end of file diff --git a/libeufin-bank/src/test/kotlin/bench.kt b/libeufin-bank/src/test/kotlin/bench.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,6 +24,7 @@ import org.postgresql.jdbc.PgConnection import tech.libeufin.bank.* import tech.libeufin.common.* import tech.libeufin.common.crypto.PwCrypto +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.test.* import java.time.Instant import java.time.LocalDateTime @@ -51,6 +52,8 @@ class Bench { val token32 = ByteArray(32) val token64 = ByteArray(64) + val accountPubs = List(amount*2) { EddsaPublicKey.randEdsaKey() } + conn.genData(amount, sequenceOf( "customers(username, name, password_hash, cashout_payto)" to { "account_$it\t$password\tMr n°$it\t$unknownPayto\n" @@ -94,6 +97,17 @@ class Bench { val uuid = UUID.randomUUID() "$uuid\t$account\t\\\\x$hex\t0\n" }, + "prepared_transfers(type, account_pub, authorization_pub, recurrent, registered_at, bank_transaction_id)" to { + val type = if (it % 2 == 0) "reserve" else "kyc" + val recurrent = if (it % 3 == 0) "true" else "false" + val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}" + val hex = accountPubs[it].raw.encodeHex() + "$type\t\\\\x$hex\t\\\\x$hex\t$recurrent\t0\t$incoming_transaction_id\n" + }, + "pending_recurrent_incoming_transactions(bank_transaction_id, debtor_account_id, authorization_pub)" to { + val hex = accountPubs[it].raw.encodeHex() + "${it*2}\t${it}\t\\\\x$hex\n" + }, "taler_exchange_outgoing(bank_transaction)" to { "${it*2-1}\n" }, @@ -136,6 +150,8 @@ class Bench { // Generate data db.conn { genData(it, AMOUNT) } + val accountPubs = List(AMOUNT) { EddsaPublicKey.randEdsaKeyPair() } + // Warm HTTP client client.get("/config").assertOk() @@ -266,7 +282,7 @@ class Bench { measureAction("withdrawal_select") { client.post("/taler-integration/withdrawal-operation/${withdrawals[it]}") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "selected_exchange" to exchangePayto } }.assertOk() @@ -331,7 +347,7 @@ class Bench { client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { json { "amount" to "KUDOS:0.0001" - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to customerPayto.canonical } }.assertOk() @@ -368,6 +384,41 @@ class Bench { }.assertNoContent() } + // Wire transfer + measureAction("wt_register") { + val (priv, pub) = accountPubs[it] + val valid_req = obj { + "credit_amount" to "KUDOS:55" + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult>() + client.post("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult>() + } + measureAction("wt_unregister") { + val (priv, pub) = accountPubs[it] + val now = Instant.now().toString() + val valid_req = obj { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertNoContent() + client.delete("/accounts/exchange/taler-wire-transfer/registration") { + json(valid_req) + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + // Delete accounts measureAction("account_delete") { diff --git a/libeufin-bank/src/test/kotlin/helpers.kt b/libeufin-bank/src/test/kotlin/helpers.kt @@ -37,6 +37,8 @@ import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull +import java.time.Duration +import java.time.Instant /* ----- Setup ----- */ @@ -158,6 +160,8 @@ fun bankSetup( }.assertNoContent() } lambda(db) + // GC everything + db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO) } } @@ -221,7 +225,7 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { json { "amount" to TalerAmount(amount) - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to merchantPayto } }.assertOk() @@ -232,7 +236,7 @@ suspend fun ApplicationTestBuilder.addKyc(amount: String) { client.postA("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") { json { "amount" to TalerAmount(amount) - "account_pub" to EddsaPublicKey.rand() + "account_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to merchantPayto } }.assertOk() @@ -298,13 +302,15 @@ suspend fun ApplicationTestBuilder.fillTanInfo(username: String) { }.assertNoContent() } -suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) { +suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String): EddsaPublicKey { + val reservePub = EddsaPublicKey.randEdsaKey() client.post("/taler-integration/withdrawal-operation/$uuid") { json { - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to reservePub "selected_exchange" to exchangePayto } }.assertOk() + return reservePub } private var nbClass = 0; @@ -405,4 +411,3 @@ fun assertException(msg: String, lambda: () -> Unit) { /* ----- Random data generation ----- */ fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand()) -fun fmtIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" -\ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/Constants.kt b/libeufin-common/src/main/kotlin/Constants.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024, 2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -27,6 +27,7 @@ const val MAX_BODY_LENGTH: Int = 4 * 1024 // 4kB // API version const val WIRE_GATEWAY_API_VERSION: String = "5:0:0" +const val WIRE_TRANSFER_API_VERSION: String = "0:0:0" const val REVENUE_API_VERSION: String = "1:1:1" const val OBSERVABILITY_API_VERSION: String = "0:0:0" diff --git a/libeufin-common/src/main/kotlin/Subject.kt b/libeufin-common/src/main/kotlin/Subject.kt @@ -20,20 +20,25 @@ package tech.libeufin.common import org.bouncycastle.math.ec.rfc8032.Ed25519 +import java.math.BigInteger +import java.security.MessageDigest sealed interface IncomingSubject { data class Reserve(val reserve_pub: EddsaPublicKey): IncomingSubject data class Kyc(val account_pub: EddsaPublicKey): IncomingSubject + data class Map(val auth_pub: EddsaPublicKey): IncomingSubject data object AdminBalanceAdjust: IncomingSubject val type: IncomingType get() = when (this) { is Reserve -> IncomingType.reserve is Kyc -> IncomingType.kyc + is Map -> IncomingType.map AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust") } val key: EddsaPublicKey get() = when (this) { is Reserve -> this.reserve_pub is Kyc -> this.account_pub + is Map -> this.auth_pub AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust") } } @@ -74,7 +79,7 @@ private data class Candidate(val subject: IncomingSubject, val quality: Base32Qu private const val ADMIN_BALANCE_ADJUST = "ADMINBALANCEADJUST" private const val KEY_SIZE = 52; -private const val KYC_SIZE = KEY_SIZE + 3; +private const val PREFIX_SIZE = KEY_SIZE + 3; private val ALPHA_NUMBERIC_PATTERN = Regex("[0-9a-zA-Z]*") /** @@ -94,15 +99,17 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { /** Parse an incoming subject */ fun parseSingle(str: String): Candidate? { // Check key type - val (isKyc, raw) = when (str.length) { + val (type, raw) = when (str.length) { ADMIN_BALANCE_ADJUST.length -> if (str.equals(ADMIN_BALANCE_ADJUST, ignoreCase = true)) { return Candidate(IncomingSubject.AdminBalanceAdjust, Base32Quality.UpperStandard) } else { return null } - KEY_SIZE -> Pair(false, str) - KYC_SIZE -> if (str.startsWith("KYC")) { - Pair(true, str.substring(3)) + KEY_SIZE -> Pair(IncomingType.reserve, str) + PREFIX_SIZE -> if (str.startsWith("KYC")) { + Pair(IncomingType.kyc, str.substring(3)) + } else if (str.startsWith("MAP")) { + Pair(IncomingType.map, str.substring(3)) } else { return null } @@ -121,7 +128,11 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { val quality = Base32Quality.measure(raw); - val subject = if (isKyc) IncomingSubject.Kyc(key) else IncomingSubject.Reserve(key) + val subject = when (type) { + IncomingType.map -> IncomingSubject.Map(key) + IncomingType.kyc -> IncomingSubject.Kyc(key) + IncomingType.reserve -> IncomingSubject.Reserve(key) + } return Candidate(subject, quality) } @@ -141,7 +152,7 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { for (end in parts.subList(i, parts.size)) { val range = start until end // Until they are to long to be a key - if (range.count() > KYC_SIZE) { + if (range.count() > PREFIX_SIZE) { break; } // Parse the concatenated parts @@ -155,14 +166,14 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { } else if (other.quality > best.quality // We prefer high quality keys || ( // We prefer prefixed keys over reserve keys best.subject.type == IncomingType.reserve && - (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.wad) + (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.map) )) { best = other } else if (best.subject.key != other.subject.key // If keys are different && best.quality == other.quality // Of same quality && !( // And prefixing is different - (best.subject.type == IncomingType.kyc || best.subject.type == IncomingType.wad) && + (best.subject.type == IncomingType.kyc || best.subject.type == IncomingType.map) && other.subject.type == IncomingType.reserve )) { @@ -178,17 +189,6 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { return best?.subject ?: throw Exception("missing reserve public key") } -/** Format an outgoing subject */ -fun fmtOutgoingSubject(wtid: ShortHashCode, url: BaseURL, metadata: String? = null): String = buildString { - if (metadata != null) { - append(metadata) - append(" ") - } - append(wtid) - append(" ") - append(url) -} - /** Extract the reserve public key from an incoming Taler transaction subject */ fun parseOutgoingSubject(subject: String): Triple<ShortHashCode, BaseURL, String?> { var iterator = subject.splitToSequence(' ').iterator(); @@ -201,4 +201,70 @@ fun parseOutgoingSubject(subject: String): Triple<ShortHashCode, BaseURL, String } else { return Triple(EddsaPublicKey(first), BaseURL.parse(second), null) } +} + +/** Format an outgoing subject */ +fun fmtOutgoingSubject(wtid: ShortHashCode, url: BaseURL, metadata: String? = null): String = buildString { + if (metadata != null) { + append(metadata) + append(" ") + } + append(wtid) + append(" ") + append(url) +} + +/** Format an incoming subject */ +fun fmtIncomingSubject(type: IncomingType, key: EddsaPublicKey): String = buildString { + append("Taler ") + when (type) { + IncomingType.kyc -> append("KYC:") + IncomingType.map -> append("MAP:") + IncomingType.reserve -> Unit + } + append(key) +} + +/** Encode a public key as a QR-Bill reference */ +fun subjectFmtQrBill(key: EddsaPublicKey): String { + // High-Entropy Hash (SHA-256) to ensure even distribution + val digest = MessageDigest.getInstance("SHA-256") + val hashInt = BigInteger(1, digest.digest(key.raw)) + + // Modulo 10^26 to fit the Swiss QR data field + val divisor = BigInteger.TEN.pow(26) + val referenceBase = hashInt.remainder(divisor).toString().padStart(26, '0') + + // Modulo 10 Recursive calculation + val lookupTable = intArrayOf(0, 9, 4, 6, 8, 2, 7, 1, 3, 5) + var carry = 0 + + for (char in referenceBase) { + val digit = char.digitToInt() + carry = lookupTable[(carry + digit) % 10] + } + + val checksum = (10 - carry) % 10 + + return referenceBase + checksum +} + +/** Check if a subject is a valid QR-Bill reference */ +fun subjectIsQrBill(reference: String): Boolean { + // Quick length and numeric check + if (reference.length != 27 || !reference.all { it.isDigit() }) { + return false + } + + // Modulo 10 Recursive check + val lookupTable = intArrayOf(0, 9, 4, 6, 8, 2, 7, 1, 3, 5) + var carry = 0 + + for (char in reference) { + val digit = char.digitToInt() + carry = lookupTable[(carry + digit) % 10] + } + + // If the check digit was correct, the final carry will be 0 + return carry == 0 } \ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -138,6 +138,10 @@ value class TalerTimestamp constructor(val instant: Instant) { } } } + + companion object { + fun never(): TalerTimestamp = TalerTimestamp(Instant.MAX) + } } @JvmInline @@ -671,12 +675,13 @@ class Base32Crockford32B { companion object { fun rand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).rand()) fun secureRand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).secureRand()) - fun randEdsaKey(): EddsaPublicKey { + fun randEdsaKey(): EddsaPublicKey = randEdsaKeyPair().second + fun randEdsaKeyPair(): Pair<ByteArray, EddsaPublicKey> { val secretKey = ByteArray(32) Ed25519.generatePrivateKey(SECURE_RNG.get(), secretKey) val publicKey = ByteArray(32) Ed25519.generatePublicKey(secretKey, 0, publicKey, 0) - return Base32Crockford32B(publicKey) + return Pair(secretKey, Base32Crockford32B(publicKey)) } } } @@ -742,6 +747,8 @@ class Base32Crockford64B { typealias ShortHashCode = Base32Crockford32B /** 64-byte hash code */ typealias HashCode = Base32Crockford64B + +typealias EddsaSignature = Base32Crockford64B /** * EdDSA and ECDHE public keys always point on Curve25519 * and represented using the standard 256 bits Ed25519 compact format, diff --git a/libeufin-common/src/main/kotlin/TalerErrorCode.kt b/libeufin-common/src/main/kotlin/TalerErrorCode.kt @@ -1,6 +1,6 @@ /* - *This file is part of GNU Taler - * Copyright (C) 2012-2025 Taler Systems SA + * This file is part of GNU Taler + * Copyright (C) 2012-2026 Taler Systems SA * GNU Taler is free software: you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as published @@ -159,6 +159,12 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed. */ GENERIC_FAILED_TO_EXPAND_TEMPLATE(75, 500, "The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed."), + /** The requested feature is not implemented by the server. The system administrator of the server may try to update the software or build it with other options to enable the feature. */ + GENERIC_FEATURE_NOT_IMPLEMENTED(76, 501, "The requested feature is not implemented by the server. The system administrator of the server may try to update the software or build it with other options to enable the feature."), + + /** The operating system failed to allocate required resources. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. */ + GENERIC_OS_RESOURCE_ALLOCATION_FAILURE(77, 500, "The operating system failed to allocate required resources. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate."), + /** Exchange is badly configured and thus cannot operate. */ EXCHANGE_GENERIC_BAD_CONFIGURATION(1000, 500, "Exchange is badly configured and thus cannot operate."), @@ -291,6 +297,24 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** A check against sanction lists failed. This is indicative of an internal error in the sanction list processing logic. This needs to be investigated by the exchange operator. */ EXCHANGE_GENERIC_KYC_SANCTION_LIST_CHECK_FAILED(1043, 500, "A check against sanction lists failed. This is indicative of an internal error in the sanction list processing logic. This needs to be investigated by the exchange operator."), + /** The process to generate a PDF from a template failed. A likely cause is a syntactic error in the template. This needs to be investigated by the exchange operator. */ + EXCHANGE_GENERIC_TYPST_TEMPLATE_FAILURE(1044, 500, "The process to generate a PDF from a template failed. A likely cause is a syntactic error in the template. This needs to be investigated by the exchange operator."), + + /** A process to combine multiple PDFs into one larger document failed. A likely cause is a resource exhaustion problem on the server. This needs to be investigated by the exchange operator. */ + EXCHANGE_GENERIC_PDFTK_FAILURE(1045, 500, "A process to combine multiple PDFs into one larger document failed. A likely cause is a resource exhaustion problem on the server. This needs to be investigated by the exchange operator."), + + /** The process to generate a PDF from a template crashed. A likely cause is a bug in the Typst software. This needs to be investigated by the exchange operator. */ + EXCHANGE_GENERIC_TYPST_CRASH(1046, 500, "The process to generate a PDF from a template crashed. A likely cause is a bug in the Typst software. This needs to be investigated by the exchange operator."), + + /** The process to combine multiple PDFs into a larger document crashed. A likely cause is a bug in the pdftk software. This needs to be investigated by the exchange operator. */ + EXCHANGE_GENERIC_PDFTK_CRASH(1047, 500, "The process to combine multiple PDFs into a larger document crashed. A likely cause is a bug in the pdftk software. This needs to be investigated by the exchange operator."), + + /** One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed. */ + EXCHANGE_GENERIC_NO_TYPST_OR_PDFTK(1048, 501, "One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed."), + + /** The exchange is not aware of the given target account. The specified account is not a customer of this service. */ + EXCHANGE_GENERIC_TARGET_ACCOUNT_UNKNOWN(1049, 404, "The exchange is not aware of the given target account. The specified account is not a customer of this service."), + /** The exchange did not find information about the specified transaction in the database. */ EXCHANGE_DEPOSITS_GET_NOT_FOUND(1100, 404, "The exchange did not find information about the specified transaction in the database."), @@ -360,8 +384,8 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The maximum age in the commitment is too large for the reserve */ EXCHANGE_WITHDRAW_MAXIMUM_AGE_TOO_LARGE(1165, 409, "The maximum age in the commitment is too large for the reserve"), - /** The batch withdraw included a planchet that was already withdrawn. This is not allowed. */ - EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET(1175, 409, "The batch withdraw included a planchet that was already withdrawn. This is not allowed."), + /** The withdraw operation included the same planchet more than once. This is not allowed. */ + EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET(1175, 400, "The withdraw operation included the same planchet more than once. This is not allowed."), /** The signature made by the coin over the deposit permission is not valid. */ EXCHANGE_DEPOSIT_COIN_SIGNATURE_INVALID(1205, 403, "The signature made by the coin over the deposit permission is not valid."), @@ -588,6 +612,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The helper refuses to sign with the key, because it is too early: the validity period has not yet started. */ EXCHANGE_SIGNKEY_HELPER_TOO_EARLY(1752, 400, "The helper refuses to sign with the key, because it is too early: the validity period has not yet started."), + /** The signatures from the master exchange public key are missing, thus the exchange cannot currently sign its API responses. The exchange operator must use taler-exchange-offline to sign the current key material. */ + EXCHANGE_SIGNKEY_HELPER_OFFLINE_MISSING(1753, 500, "The signatures from the master exchange public key are missing, thus the exchange cannot currently sign its API responses. The exchange operator must use taler-exchange-offline to sign the current key material."), + /** The purse expiration time is in the past at the time of its creation. */ EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW(1775, 400, "The purse expiration time is in the past at the time of its creation."), @@ -888,12 +915,15 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The start and end-times in the wire fee structure leave a hole. This is not allowed. */ MERCHANT_GENERIC_HOLE_IN_WIRE_FEE_STRUCTURE(2001, 0, "The start and end-times in the wire fee structure leave a hole. This is not allowed."), - /** The merchant was unable to obtain a valid answer to /wire from the exchange. */ - MERCHANT_GENERIC_EXCHANGE_WIRE_REQUEST_FAILED(2002, 502, "The merchant was unable to obtain a valid answer to /wire from the exchange."), + /** The master key of the exchange does not match the one configured for this merchant. As a result, we refuse to do business with this exchange. The administrator should check if they configured the exchange correctly in the merchant backend. */ + MERCHANT_GENERIC_EXCHANGE_MASTER_KEY_MISMATCH(2002, 502, "The master key of the exchange does not match the one configured for this merchant. As a result, we refuse to do business with this exchange. The administrator should check if they configured the exchange correctly in the merchant backend."), /** The product category is not known to the backend. */ MERCHANT_GENERIC_CATEGORY_UNKNOWN(2003, 404, "The product category is not known to the backend."), + /** The unit referenced in the request is not known to the backend. */ + MERCHANT_GENERIC_UNIT_UNKNOWN(2004, 404, "The unit referenced in the request is not known to the backend."), + /** The proposal is not known to the backend. */ MERCHANT_GENERIC_ORDER_UNKNOWN(2005, 404, "The proposal is not known to the backend."), @@ -966,6 +996,42 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The merchant backend is not configured to support the DONAU protocol. */ MERCHANT_GENERIC_DONAU_NOT_CONFIGURED(2028, 501, "The merchant backend is not configured to support the DONAU protocol."), + /** The public signing key given in the exchange response is not in the current keys response. It is possible that the operation will succeed later after the merchant has downloaded an updated keys response. */ + MERCHANT_EXCHANGE_SIGN_PUB_UNKNOWN(2029, 0, "The public signing key given in the exchange response is not in the current keys response. It is possible that the operation will succeed later after the merchant has downloaded an updated keys response."), + + /** The merchant backend does not support the requested feature. */ + MERCHANT_GENERIC_FEATURE_NOT_AVAILABLE(2030, 501, "The merchant backend does not support the requested feature."), + + /** This operation requires multi-factor authorization and the respective instance does not have a sufficient number of factors that could be validated configured. You need to ask the system administrator to perform this operation. */ + MERCHANT_GENERIC_MFA_MISSING(2031, 403, "This operation requires multi-factor authorization and the respective instance does not have a sufficient number of factors that could be validated configured. You need to ask the system administrator to perform this operation."), + + /** A donation authority (Donau) provided an invalid response. This should be analyzed by the administrator. Trying again later may help. */ + MERCHANT_GENERIC_DONAU_INVALID_RESPONSE(2032, 502, "A donation authority (Donau) provided an invalid response. This should be analyzed by the administrator. Trying again later may help."), + + /** The unit referenced in the request is builtin and cannot be modified or deleted. */ + MERCHANT_GENERIC_UNIT_BUILTIN(2033, 409, "The unit referenced in the request is builtin and cannot be modified or deleted."), + + /** The report ID provided to the backend is not known to the backend. */ + MERCHANT_GENERIC_REPORT_UNKNOWN(2034, 404, "The report ID provided to the backend is not known to the backend."), + + /** The report ID provided to the backend is not known to the backend. */ + MERCHANT_GENERIC_REPORT_GENERATOR_UNCONFIGURED(2035, 501, "The report ID provided to the backend is not known to the backend."), + + /** The product group ID provided to the backend is not known to the backend. */ + MERCHANT_GENERIC_PRODUCT_GROUP_UNKNOWN(2036, 404, "The product group ID provided to the backend is not known to the backend."), + + /** The money pod ID provided to the backend is not known to the backend. */ + MERCHANT_GENERIC_MONEY_POT_UNKNOWN(2037, 404, "The money pod ID provided to the backend is not known to the backend."), + + /** The session ID provided to the backend is not known to the backend. */ + MERCHANT_GENERIC_SESSION_UNKNOWN(2038, 404, "The session ID provided to the backend is not known to the backend."), + + /** The merchant does not have a charity associated with the selected Donau. As a result, it cannot generate the requested donation receipt. This could happen if the charity was removed from the backend between order creation and payment. */ + MERCHANT_GENERIC_DONAU_CHARITY_UNKNOWN(2039, 404, "The merchant does not have a charity associated with the selected Donau. As a result, it cannot generate the requested donation receipt. This could happen if the charity was removed from the backend between order creation and payment."), + + /** The merchant does not expect any transfer with the given ID and can thus not return any details about it. */ + MERCHANT_GENERIC_EXPECTED_TRANSFER_UNKNOWN(2040, 404, "The merchant does not expect any transfer with the given ID and can thus not return any details about it."), + /** The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response. */ MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE(2100, 200, "The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response."), @@ -981,8 +1047,29 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The contract terms hash used to authenticate the client is invalid for this order. */ MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_HASH(2106, 403, "The contract terms hash used to authenticate the client is invalid for this order."), - /** The contract terms version is not invalid. */ - MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION(2107, 403, "The contract terms version is not invalid."), + /** The contract terms version is not understood by the merchant backend. Most likely the merchant backend was downgraded to a version incompatible with the content of the database. */ + MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION(2107, 500, "The contract terms version is not understood by the merchant backend. Most likely the merchant backend was downgraded to a version incompatible with the content of the database."), + + /** The provided TAN code is invalid for this challenge. */ + MERCHANT_TAN_CHALLENGE_FAILED(2125, 409, "The provided TAN code is invalid for this challenge."), + + /** The backend is not aware of the specified MFA challenge. */ + MERCHANT_TAN_CHALLENGE_UNKNOWN(2126, 404, "The backend is not aware of the specified MFA challenge."), + + /** There have been too many attempts to solve the challenge. A new TAN must be requested. */ + MERCHANT_TAN_TOO_MANY_ATTEMPTS(2127, 429, "There have been too many attempts to solve the challenge. A new TAN must be requested."), + + /** The backend failed to launch a helper process required for the multi-factor authentication step. The backend operator should check the logs and fix the Taler merchant backend configuration. */ + MERCHANT_TAN_MFA_HELPER_EXEC_FAILED(2128, 502, "The backend failed to launch a helper process required for the multi-factor authentication step. The backend operator should check the logs and fix the Taler merchant backend configuration."), + + /** The challenge was already solved. Thus, we refuse to send it again. */ + MERCHANT_TAN_CHALLENGE_SOLVED(2129, 410, "The challenge was already solved. Thus, we refuse to send it again."), + + /** It is too early to request another transmission of the challenge. The client should wait and see if they received the previous challenge. */ + MERCHANT_TAN_TOO_EARLY(2130, 429, "It is too early to request another transmission of the challenge. The client should wait and see if they received the previous challenge."), + + /** There have been too many attempts to solve MFA. The client may attempt again in the future. */ + MERCHANT_MFA_FORBIDDEN(2131, 403, "There have been too many attempts to solve MFA. The client may attempt again in the future."), /** The exchange responded saying that funds were insufficient (for example, due to double-spending). */ MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS(2150, 409, "The exchange responded saying that funds were insufficient (for example, due to double-spending)."), @@ -1038,8 +1125,8 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** According to our database, we have refunded more than we were paid (which should not be possible). */ MERCHANT_POST_ORDERS_ID_PAY_REFUNDS_EXCEED_PAYMENTS(2168, 500, "According to our database, we have refunded more than we were paid (which should not be possible)."), - /** Legacy stuff. Remove me with protocol v1. */ - DEAD_QQQ_PAY_MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE(2169, 0, "Legacy stuff. Remove me with protocol v1."), + /** The refund request is too late because it is past the wire transfer deadline of the order. The merchant must find a different way to pay back the money to the customer. */ + MERCHANT_PRIVATE_POST_REFUND_AFTER_WIRE_DEADLINE(2169, 410, "The refund request is too late because it is past the wire transfer deadline of the order. The merchant must find a different way to pay back the money to the customer."), /** The payment failed at the exchange. */ MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_FAILED(2170, 502, "The payment failed at the exchange."), @@ -1086,6 +1173,12 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The payment violates a transaction limit configured at the given exchange. The wallet has a bug in that it failed to check exchange limits during coin selection. Please report the bug to your wallet developer. */ MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION(2184, 400, "The payment violates a transaction limit configured at the given exchange. The wallet has a bug in that it failed to check exchange limits during coin selection. Please report the bug to your wallet developer."), + /** The donation amount provided in the BKPS does not match the amount of the order choice. */ + MERCHANT_POST_ORDERS_ID_PAY_DONATION_AMOUNT_MISMATCH(2185, 409, "The donation amount provided in the BKPS does not match the amount of the order choice."), + + /** Some of the exchanges involved refused the request for reasons related to legitimization. The wallet should try with coins of different exchanges. The merchant should check if they have some legitimization process pending at the exchange. */ + MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED(2186, 451, "Some of the exchanges involved refused the request for reasons related to legitimization. The wallet should try with coins of different exchanges. The merchant should check if they have some legitimization process pending at the exchange."), + /** The contract hash does not match the given order ID. */ MERCHANT_POST_ORDERS_ID_PAID_CONTRACT_HASH_MISMATCH(2200, 400, "The contract hash does not match the given order ID."), @@ -1254,12 +1347,21 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The backend could not delete the transfer as the echange already replied to our inquiry about it and we have integrated the result. */ MERCHANT_PRIVATE_DELETE_TRANSFERS_ALREADY_CONFIRMED(2556, 409, "The backend could not delete the transfer as the echange already replied to our inquiry about it and we have integrated the result."), - /** The backend was previously informed about a wire transfer with the same ID but a different amount. Multiple wire transfers with the same ID are not allowed. If the new amount is correct, the old transfer should first be deleted. */ - MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION(2557, 409, "The backend was previously informed about a wire transfer with the same ID but a different amount. Multiple wire transfers with the same ID are not allowed. If the new amount is correct, the old transfer should first be deleted."), + /** The backend could not persist the wire transfer due to the state of the backend. This usually means that a wire transfer with the same wire transfer subject but a different amount was previously submitted to the backend. */ + MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION(2557, 409, "The backend could not persist the wire transfer due to the state of the backend. This usually means that a wire transfer with the same wire transfer subject but a different amount was previously submitted to the backend."), + + /** The target bank account given by the exchange is not (or no longer) known at the merchant instance. */ + MERCHANT_EXCHANGE_TRANSFERS_TARGET_ACCOUNT_UNKNOWN(2558, 0, "The target bank account given by the exchange is not (or no longer) known at the merchant instance."), /** The amount transferred differs between what was submitted and what the exchange claimed. */ MERCHANT_EXCHANGE_TRANSFERS_CONFLICTING_TRANSFERS(2563, 0, "The amount transferred differs between what was submitted and what the exchange claimed."), + /** The report ID provided to the backend is not known to the backend. */ + MERCHANT_REPORT_GENERATOR_FAILED(2570, 501, "The report ID provided to the backend is not known to the backend."), + + /** Failed to fetch the data for the report from the backend. */ + MERCHANT_REPORT_FETCH_FAILED(2571, 502, "Failed to fetch the data for the report from the backend."), + /** The merchant backend cannot create an instance under the given identifier as one already exists. Use PATCH to modify the existing entry. */ MERCHANT_PRIVATE_POST_INSTANCES_ALREADY_EXISTS(2600, 409, "The merchant backend cannot create an instance under the given identifier as one already exists. Use PATCH to modify the existing entry."), @@ -1281,6 +1383,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The bank account specified in the request already exists at the merchant. */ MERCHANT_PRIVATE_ACCOUNT_EXISTS(2627, 409, "The bank account specified in the request already exists at the merchant."), + /** The bank account specified is not acceptable for this exchange. The exchange either does not support the wire method or something else about the specific account. Consult the exchange account constraints and specify a different bank account if you want to use this exchange. */ + MERCHANT_PRIVATE_ACCOUNT_NOT_ELIGIBLE_FOR_EXCHANGE(2628, 409, "The bank account specified is not acceptable for this exchange. The exchange either does not support the wire method or something else about the specific account. Consult the exchange account constraints and specify a different bank account if you want to use this exchange."), + /** The product ID exists. */ MERCHANT_PRIVATE_POST_PRODUCTS_CONFLICT_PRODUCT_EXISTS(2650, 409, "The product ID exists."), @@ -1302,8 +1407,17 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The lock request is for more products than we have left (unlocked) in stock. */ MERCHANT_PRIVATE_POST_PRODUCTS_LOCK_INSUFFICIENT_STOCKS(2670, 410, "The lock request is for more products than we have left (unlocked) in stock."), - /** The deletion request is for a product that is locked. */ - MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK(2680, 409, "The deletion request is for a product that is locked."), + /** The deletion request is for a product that is locked. The product cannot be deleted until the existing offer to expires. */ + MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK(2680, 409, "The deletion request is for a product that is locked. The product cannot be deleted until the existing offer to expires."), + + /** The proposed name for the product group is already in use. You should select a different name. */ + MERCHANT_PRIVATE_PRODUCT_GROUP_CONFLICTING_NAME(2690, 409, "The proposed name for the product group is already in use. You should select a different name."), + + /** The proposed name for the money pot is already in use. You should select a different name. */ + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_NAME(2691, 409, "The proposed name for the money pot is already in use. You should select a different name."), + + /** The total amount in the money pot is different from the amount required by the request. The client should fetch the current pot total and retry with the latest amount to succeed. */ + MERCHANT_PRIVATE_MONEY_POT_CONFLICTING_TOTAL(2692, 409, "The total amount in the money pot is different from the amount required by the request. The client should fetch the current pot total and retry with the latest amount to succeed."), /** The requested wire method is not supported by the exchange. */ MERCHANT_PRIVATE_POST_RESERVES_UNSUPPORTED_WIRE_METHOD(2700, 409, "The requested wire method is not supported by the exchange."), @@ -1347,6 +1461,15 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** Subject not given in the using template and in the template contract. There is a conflict. */ MERCHANT_POST_USING_TEMPLATES_NO_SUMMARY(2863, 409, "Subject not given in the using template and in the template contract. There is a conflict."), + /** The selected template has a different type than the one specified in the request of the client. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type. */ + MERCHANT_POST_USING_TEMPLATES_WRONG_TYPE(2864, 409, "The selected template has a different type than the one specified in the request of the client. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type."), + + /** The selected template does not allow one of the specified products to be included in the order. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type. */ + MERCHANT_POST_USING_TEMPLATES_WRONG_PRODUCT(2865, 409, "The selected template does not allow one of the specified products to be included in the order. This may happen if the template was updated since the last time the client fetched it. The client should re-fetch the current template and send a request of the correct type."), + + /** The selected combination of products does not allow the backend to compute a price for the order in any of the supported currencies. This may happen if the template was updated since the last time the client fetched it or if the wallet assembled an unsupported combination of products. The site administrator might want to specify additional prices for products, while the client should re-fetch the current template and send a request with a combination of products for which prices exist in the same currency. */ + MERCHANT_POST_USING_TEMPLATES_NO_CURRENCY(2866, 409, "The selected combination of products does not allow the backend to compute a price for the order in any of the supported currencies. This may happen if the template was updated since the last time the client fetched it or if the wallet assembled an unsupported combination of products. The site administrator might want to specify additional prices for products, while the client should re-fetch the current template and send a request with a combination of products for which prices exist in the same currency."), + /** The webhook ID elready exists. */ MERCHANT_PRIVATE_POST_WEBHOOKS_CONFLICT_WEBHOOK_EXISTS(2900, 409, "The webhook ID elready exists."), @@ -1539,6 +1662,18 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The client tried to use an already taken name. */ BANK_NAME_REUSE(5157, 409, "The client tried to use an already taken name."), + /** This subject format is not supported. */ + BANK_UNSUPPORTED_SUBJECT_FORMAT(5158, 409, "This subject format is not supported."), + + /** The derived subject is already used. */ + BANK_DERIVATION_REUSE(5159, 409, "The derived subject is already used."), + + /** The provided signature is invalid. */ + BANK_BAD_SIGNATURE(5160, 409, "The provided signature is invalid."), + + /** The provided timestamp is too old. */ + BANK_OLD_TIMESTAMP(5161, 409, "The provided timestamp is too old."), + /** The sync service failed find the account in its database. */ SYNC_ACCOUNT_UNKNOWN(6100, 404, "The sync service failed find the account in its database."), @@ -1614,9 +1749,6 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The signature on a coin by the exchange's denomination key is invalid after unblinding it. */ WALLET_EXCHANGE_COIN_SIGNATURE_INVALID(7009, 0, "The signature on a coin by the exchange's denomination key is invalid after unblinding it."), - /** The exchange does not know about the reserve (yet), and thus withdrawal can't progress. */ - WALLET_EXCHANGE_WITHDRAW_RESERVE_UNKNOWN_AT_EXCHANGE(7010, 404, "The exchange does not know about the reserve (yet), and thus withdrawal can't progress."), - /** The wallet core service is not available. */ WALLET_CORE_NOT_AVAILABLE(7011, 0, "The wallet core service is not available."), @@ -1731,6 +1863,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** A parameter in the request is malformed or missing. */ WALLET_CORE_API_BAD_REQUEST(7048, 0, "A parameter in the request is malformed or missing."), + /** The order could not be found. Maybe the merchant deleted it. */ + WALLET_MERCHANT_ORDER_NOT_FOUND(7049, 0, "The order could not be found. Maybe the merchant deleted it."), + /** We encountered a timeout with our payment backend. */ ANASTASIS_GENERIC_BACKEND_TIMEOUT(8000, 504, "We encountered a timeout with our payment backend."), @@ -1962,6 +2097,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The client reused a unique donor identifier nonce, which is not allowed. */ DONAU_DONOR_IDENTIFIER_NONCE_REUSE(8617, 409, "The client reused a unique donor identifier nonce, which is not allowed."), + /** A charity with the same public key is already registered. */ + DONAU_CHARITY_PUB_EXISTS(8618, 404, "A charity with the same public key is already registered."), + /** A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. */ LIBEUFIN_NEXUS_GENERIC_ERROR(9000, 0, "A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information."), diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable enum class IncomingType { reserve, kyc, - wad + map } enum class TransferStatusState { @@ -78,6 +78,7 @@ data class TransferList( val transfers: List<TransferListStatus>, val debit_account: String ) + @Serializable data class TransferListStatus( val row_id: Long, @@ -149,7 +150,8 @@ data class IncomingKycAuthTransaction( override val credit_fee: TalerAmount? = null, override val debit_account: String, val account_pub: EddsaPublicKey -): IncomingBankTransaction +) : IncomingBankTransaction + @Serializable @SerialName("RESERVE") data class IncomingReserveTransaction( @@ -159,7 +161,8 @@ data class IncomingReserveTransaction( override val credit_fee: TalerAmount? = null, override val debit_account: String, val reserve_pub: EddsaPublicKey -): IncomingBankTransaction +) : IncomingBankTransaction + @Serializable @SerialName("WAD") data class IncomingWadTransaction( @@ -170,7 +173,7 @@ data class IncomingWadTransaction( override val debit_account: String, val origin_exchange_url: String, val wad_id: String // TODO 24 bytes Base32 -): IncomingBankTransaction +) : IncomingBankTransaction /** Response GET /taler-wire-gateway/history/outgoing */ @Serializable @@ -196,6 +199,83 @@ data class OutgoingTransaction( @Serializable class AccountInfo() +/** Response GET /taler-wire-transfer/config */ +@Serializable +data class WireTransferConfig( + val currency: String, + val supported_formats: List<SubjectFormat> +) { + val name: String = "taler-wire-transfer" + val version: String = WIRE_TRANSFER_API_VERSION +} + + +/** Inner response GET /taler-wire-transfer/registration */ +@Serializable +sealed interface TransferSubject { + @Serializable + @SerialName("SIMPLE") + data class Simple( + val subject: String, + val credit_amount: TalerAmount + ) : TransferSubject + + @Serializable + @SerialName("URI") + data class Uri( + val uri: String + ) : TransferSubject + + @Serializable + @SerialName("CH_QR_BILL") + data class QrBill( + val qr_reference_number: String, + val credit_amount: TalerAmount, + ) : TransferSubject +} + +@Serializable +enum class SubjectFormat { + SIMPLE, + URI, + CH_QR_BILL +} + +@Serializable +enum class PublicKeyAlg { + ECDSA +} + +@Serializable +enum class TransferType { + reserve, + kyc +} + +@Serializable +data class SubjectRequest( + val credit_amount: TalerAmount, + val type: TransferType, + val alg: PublicKeyAlg, + val account_pub: EddsaPublicKey, + val authorization_pub: EddsaPublicKey, + val authorization_signature: EddsaSignature, + val recurrent: Boolean +) + +@Serializable +data class SubjectResult( + val subjects: List<TransferSubject>, + val expiration: TalerTimestamp +) + +@Serializable +data class Unregistration( + val timestamp: String, + val authorization_pub: EddsaPublicKey, + val authorization_signature: EddsaSignature +) + /** Response GET /taler-revenue/config */ @Serializable data class RevenueConfig( @@ -208,7 +288,7 @@ data class RevenueConfig( /** Request GET /taler-revenue/history */ @Serializable data class RevenueIncomingHistory( - val incoming_transactions : List<RevenueIncomingBankTransaction>, + val incoming_transactions: List<RevenueIncomingBankTransaction>, val credit_account: String ) diff --git a/libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt b/libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -28,6 +28,9 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.generators.BCrypt import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import tech.libeufin.common.* @@ -301,4 +304,24 @@ object CryptoUtil { val hash = mfaBodyHash(body, salt) return Pair(hash, salt) } + + fun checkEdssaSignature(data: ByteArray, signature: EddsaSignature, publicKey: EddsaPublicKey): Boolean { + val pubKey = Ed25519PublicKeyParameters(publicKey.raw, 0) + + val verifier = Ed25519Signer() + verifier.init(false, pubKey) + verifier.update(data, 0, data.size) + + return verifier.verifySignature(signature.raw) + } + + fun eddsaSign(data: ByteArray, privateKey: ByteArray): EddsaSignature { + val privateKey = Ed25519PrivateKeyParameters(privateKey, 0) + + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(data, 0, data.size) + + return EddsaSignature(signer.generateSignature()) + } } \ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/db/types.kt b/libeufin-common/src/main/kotlin/db/types.kt @@ -47,6 +47,8 @@ inline fun <reified T : Enum<T>> ResultSet.getEnum(idx: Int): T inline fun <reified T : Enum<T>> ResultSet.getOptEnum(name: String): T? = getString(name)?.run { java.lang.Enum.valueOf(T::class.java, this) } +inline fun <reified T : Enum<T>> ResultSet.getOptEnum(idx: Int): T? + = getString(idx)?.run { java.lang.Enum.valueOf(T::class.java, this) } inline fun <reified T : Enum<T>> ResultSet.getEnumSet(name: String): Set<T> { val sqlArray: java.sql.Array = this.getArray(name) @@ -56,6 +58,11 @@ inline fun <reified T : Enum<T>> ResultSet.getEnumSet(name: String): Set<T> { return set } +inline fun <reified T> ResultSet.getOptObject(column: String): T? { + val value = this.getObject(column, T::class.java) + return if (this.wasNull()) null else value as T +} + fun ResultSet.getOptLong(name: String): Long? { val nb = getLong(name) if (wasNull()) return null diff --git a/libeufin-common/src/main/kotlin/test/bench.kt b/libeufin-common/src/main/kotlin/test/bench.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -31,7 +31,7 @@ import kotlin.time.toDuration fun PgConnection.genData(amount: Int, generators: Sequence<Pair<String, (Int) -> String>>) { for ((table, generator) in generators) { println("Gen rows for $table") - PGCopyOutputStream(this, "COPY $table FROM STDIN", 1024 * 1024).use { out -> + PGCopyOutputStream(this, "COPY $table FROM STDIN", 16 * 1024 * 1024).use { out -> repeat(amount) { val str = generator(it+1) val bytes = str.toByteArray() @@ -41,7 +41,7 @@ fun PgConnection.genData(amount: Int, generators: Sequence<Pair<String, (Int) -> } // Update database statistics for better perf - this.execSQLUpdate("VACUUM ANALYZE") + this.execSQLUpdate("VACUUM FULL ANALYZE") } class Benchmark(private val iter: Int) { diff --git a/libeufin-common/src/test/kotlin/SubjectTest.kt b/libeufin-common/src/test/kotlin/SubjectTest.kt @@ -33,18 +33,22 @@ class SubjectTest { val key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; val other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; - for (ty in sequenceOf(IncomingType.reserve, IncomingType.kyc)) { - val prefix = if (ty == IncomingType.kyc) "KYC" else ""; + for (ty in sequenceOf(IncomingType.reserve, IncomingType.kyc, IncomingType.map)) { + val prefix = when (ty) { + IncomingType.reserve -> "" + IncomingType.kyc -> "KYC" + IncomingType.map -> "MAP" + } val standard = "$prefix$key" val (standardL, standardR) = standard.chunked(standard.length / 2 + 1) val mixed = "${prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" val (mixedL, mixedR) = mixed.chunked(mixed.length / 2 + 1) val other_standard = "$prefix$other" val other_mixed = "${prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60" - val key = if (ty == IncomingType.reserve) { - IncomingSubject.Reserve(EddsaPublicKey(key)) - } else { - IncomingSubject.Kyc(EddsaPublicKey(key)) + val key = when (ty) { + IncomingType.reserve -> IncomingSubject.Reserve(EddsaPublicKey(key)) + IncomingType.kyc -> IncomingSubject.Kyc(EddsaPublicKey(key)) + IncomingType.map -> IncomingSubject.Map(EddsaPublicKey(key)) } // Check succeed if standard or mixed diff --git a/libeufin-nexus/conf/auth.conf b/libeufin-nexus/conf/auth.conf @@ -20,6 +20,9 @@ AUTH_METHOD = basic USERNAME = username PASSWORD = password +[nexus-httpd-wire-transfer-api] +ENABLED = YES + [nexus-httpd-revenue-api] ENABLED = YES AUTH_METHOD = bearer-token diff --git a/libeufin-nexus/conf/test.conf b/libeufin-nexus/conf/test.conf @@ -19,6 +19,9 @@ ENABLED = YES AUTH_METHOD = bearer TOKEN = secret-token +[nexus-httpd-wire-transfer-api] +ENABLED = YES + [nexus-httpd-revenue-api] ENABLED = YES AUTH_METHOD = bearer diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -110,8 +110,11 @@ class NexusEbicsConfig( override val clientPrivateKeysPath = sect.path("client_private_keys_file").require() } -class ApiConfig(section: TalerConfigSection) { - val authMethod = section.requireAuthMethod() +class ApiConfig( + section: TalerConfigSection, + authMethod: AuthMethod? = null +) { + val authMethod = authMethod ?: section.requireAuthMethod() } /** Configuration for libeufin-nexus */ @@ -146,6 +149,7 @@ class NexusConfig internal constructor (val cfg: TalerConfig) { ) val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf() + val wireTransferApiCfg = cfg.section("nexus-httpd-wire-transfer-api").apiConf(AuthMethod.None) val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf() val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf() } @@ -157,10 +161,10 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { ) } -private fun TalerConfigSection.apiConf(): ApiConfig? { +private fun TalerConfigSection.apiConf(authMethod: AuthMethod? = null): ApiConfig? { val enabled = boolean("enabled").require() return if (enabled) { - return ApiConfig(this) + return ApiConfig(this, authMethod) } else { null } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -32,6 +32,7 @@ import kotlinx.serialization.Contextual import tech.libeufin.common.api.talerApi import tech.libeufin.common.setupSecurityProperties import tech.libeufin.nexus.api.revenueApi +import tech.libeufin.nexus.api.wireTransferApi import tech.libeufin.nexus.api.wireGatewayApi import tech.libeufin.nexus.api.observabilityApi import tech.libeufin.nexus.cli.LibeufinNexus @@ -48,6 +49,7 @@ data class IbanAccountMetadata( fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(LoggerFactory.getLogger("libeufin-nexus-api")) { wireGatewayApi(db, cfg) + wireTransferApi(db, cfg) revenueApi(db, cfg) observabilityApi(db, cfg) } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -39,10 +39,12 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) { get("/taler-wire-gateway/config") { - call.respond(WireGatewayConfig( - currency = cfg.currency, - support_account_check = true - )) + call.respond( + WireGatewayConfig( + currency = cfg.currency, + support_account_check = true + ) + ) } auth(cfg.wireGatewayApiCfg) { post("/taler-wire-gateway/transfer") { @@ -60,10 +62,12 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) + TransferResult.WtidReuse -> throw conflict( "wtid used already", TalerErrorCode.BANK_TRANSFER_WTID_REUSED ) + is TransferResult.Success -> call.respond( TransferResponse( timestamp = res.timestamp, @@ -91,7 +95,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir call.respond(transfer) } suspend fun <T> ApplicationCall.historyEndpoint( - reduce: (List<T>, String) -> Any, + reduce: (List<T>, String) -> Any, dbLambda: suspend ExchangeDAO.(HistoryParams) -> List<T> ) { val params = HistoryParams.extract(this.request.queryParameters) @@ -120,18 +124,22 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir cfg.checkCurrency(amount) val debitAccount = debitAccount.expectIban() val timestamp = Instant.now() - val res = db.payment.registerTalerableIncoming(IncomingPayment( - amount = amount, - debtor = debitAccount, - subject = subject, - executionTime = timestamp, - id = IncomingId(null, randEbicsId(), null) - ), metadata) + val res = db.payment.registerTalerableIncoming( + IncomingPayment( + amount = amount, + debtor = debitAccount, + subject = subject, + executionTime = timestamp, + id = IncomingId(null, randEbicsId(), null) + ), metadata + ) when (res) { IncomingRegistrationResult.ReservePubReuse -> throw conflict( "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) + IncomingRegistrationResult.MappingReuse, + IncomingRegistrationResult.UnknownMapping -> throw UnsupportedOperationException("mapping not used") is IncomingRegistrationResult.Success -> respond( AddIncomingResponse( timestamp = TalerTimestamp(timestamp), @@ -159,4 +167,4 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir ) } } -} -\ No newline at end of file +} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt @@ -0,0 +1,116 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.pipeline.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.NexusConfig +import tech.libeufin.nexus.checkCurrency +import tech.libeufin.nexus.db.Database +import tech.libeufin.nexus.db.ExchangeDAO +import tech.libeufin.nexus.db.ExchangeDAO.TransferResult +import tech.libeufin.nexus.db.TransferDAO.RegistrationResult +import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult +import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.randEbicsId +import java.time.Instant +import java.time.Duration + +fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireTransferApiCfg) { + get("/taler-wire-transfer/config") { + call.respond( + WireTransferConfig( + currency = cfg.currency, + supported_formats = listOf(SubjectFormat.SIMPLE, SubjectFormat.CH_QR_BILL) + ) + ) + } + post("/taler-wire-transfer/registration") { + val req = call.receive<SubjectRequest>(); + + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_signature, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + val reference = subjectFmtQrBill(req.authorization_pub) + + when (val result = db.transfer.register( + req.type, + req.account_pub, + req.authorization_pub, + req.recurrent, + reference, + Instant.now() + )) { + RegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + RegistrationResult.SubjectReuse -> throw conflict( + "subject derivation used already", + TalerErrorCode.BANK_DERIVATION_REUSE + ) + RegistrationResult.Success -> { + call.respond( + SubjectResult( + listOf( + TransferSubject.QrBill(reference, req.credit_amount), + TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount) + ), + TalerTimestamp.never() + ) + ) + } + } + } + delete("/taler-wire-transfer/registration") { + val req = call.receive<Unregistration>(); + + val timestamp = Instant.parse(req.timestamp) + + if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + throw conflict( + "timestamp too old", + TalerErrorCode.BANK_OLD_TIMESTAMP + ) + + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_signature, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { + call.respond(HttpStatusCode.NoContent) + } else { + throw notFound( + "Prepared transfer '${req.authorization_pub}' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + } + } +} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -177,20 +177,31 @@ suspend fun registerIncomingPayment( } } // Else we try to parse the incoming subject - runCatching { parseIncomingSubject(payment.subject) }.fold( - onSuccess = { metadata -> - if (metadata is IncomingSubject.AdminBalanceAdjust) { - val res = db.payment.registerIncoming(payment) - logRes(res, kind = "admin balance adjust") - } else { - when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { - IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") - is IncomingRegistrationResult.Success -> logRes(res) + if (payment.subject != null && subjectIsQrBill(payment.subject)) { + when (val res = db.payment.registerQrBillIncoming(payment, payment.subject)) { + IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") + IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse") + IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping") + is IncomingRegistrationResult.Success -> logRes(res) + } + } else { + runCatching { parseIncomingSubject(payment.subject) }.fold( + onSuccess = { metadata -> + if (metadata is IncomingSubject.AdminBalanceAdjust) { + val res = db.payment.registerIncoming(payment) + logRes(res, kind = "admin balance adjust") + } else { + when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { + IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") + IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse") + IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping") + is IncomingRegistrationResult.Success -> logRes(res) + } } - } - }, - onFailure = { e -> bounce(e.fmt())} - ) + }, + onFailure = { e -> bounce(e.fmt())} + ) + } } /** Register a [tx] notification into [db] */ diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -94,6 +94,7 @@ class Database(dbConfig: DatabaseConfig, val currency: String): DbPool(dbConfig, val ebics = EbicsDAO(this) val list = ListDAO(this) val kv = KvDAO(this) + val transfer = TransferDAO(this) private val outgoingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() private val incomingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -61,7 +61,7 @@ class ExchangeDAO(private val db: Database) { debit_account = it.getString("debit_payto"), account_pub = EddsaPublicKey(it.getBytes("metadata")), ) - IncomingType.wad -> throw UnsupportedOperationException() + IncomingType.map -> throw UnsupportedOperationException() } } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -45,10 +45,12 @@ class ListDAO(private val db: Database) { ,uetr ,tx_id ,acct_svcr_ref + ,authorization_pub FROM incoming_transactions AS incoming LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN bounced_transactions USING (incoming_transaction_id) LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) ${if (incomplete) { "WHERE debit_payto IS NULL OR incoming.subject IS NULL" } else { ""}} ORDER BY execution_time """ @@ -68,10 +70,13 @@ class ListDAO(private val db: Database) { debtor = it.getString("debit_payto"), bounced = it.getString("bounced"), talerable = when (type) { - null -> null + null -> { + // TODO map + null + } IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}" - IncomingType.wad -> throw UnsupportedOperationException() + IncomingType.map -> throw UnsupportedOperationException() } ) } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -123,8 +123,16 @@ class PaymentDAO(private val db: Database) { /** Incoming payments registration result */ sealed interface IncomingRegistrationResult { - data class Success(val id: Long, override val new: Boolean, override val completed: Boolean, override val bounceId: String?): IncomingRegistrationResult, InResult + data class Success( + val id: Long, + override val new: Boolean, + override val completed: Boolean, + override val bounceId: String?, + val pending: Boolean + ): IncomingRegistrationResult, InResult data object ReservePubReuse: IncomingRegistrationResult + data object MappingReuse: IncomingRegistrationResult + data object UnknownMapping: IncomingRegistrationResult } /** Register an talerable incoming payment */ @@ -133,8 +141,16 @@ class PaymentDAO(private val db: Database) { metadata: IncomingSubject ): IncomingRegistrationResult = db.serializable( """ - SELECT out_reserve_pub_reuse, out_found, out_completed, out_tx_id, out_bounce_id - FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?) + SELECT + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping, + out_found, + out_completed, + out_pending, + out_tx_id, + out_bounce_id + FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?,NULL) """ ) { bind(payment.amount) @@ -150,11 +166,57 @@ class PaymentDAO(private val db: Database) { one { when { it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse + it.getBoolean("out_mapping_reuse") -> IncomingRegistrationResult.MappingReuse + it.getBoolean("out_unknown_mapping") -> IncomingRegistrationResult.UnknownMapping else -> IncomingRegistrationResult.Success( it.getLong("out_tx_id"), !it.getBoolean("out_found"), it.getBoolean("out_completed"), it.getString("out_bounce_id"), + it.getBoolean("out_pending") + ) + } + } + } + + /** Register an QR-Bill incoming payment */ + suspend fun registerQrBillIncoming( + payment: IncomingPayment, + reference: String + ): IncomingRegistrationResult = db.serializable( + """ + SELECT + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping, + out_found, + out_completed, + out_pending, + out_tx_id, + out_bounce_id + FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL,?) + """ + ) { + bind(payment.amount) + bind(payment.creditFee ?: TalerAmount.zero(db.currency)) + bind(payment.subject) + bind(payment.executionTime) + bind(payment.debtor?.toString()) + bind(payment.id.uetr) + bind(payment.id.txId) + bind(payment.id.acctSvcrRef) + bind(reference) + one { + when { + it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse + it.getBoolean("out_mapping_reuse") -> IncomingRegistrationResult.MappingReuse + it.getBoolean("out_unknown_mapping") -> IncomingRegistrationResult.UnknownMapping + else -> IncomingRegistrationResult.Success( + it.getLong("out_tx_id"), + !it.getBoolean("out_found"), + it.getBoolean("out_completed"), + it.getString("out_bounce_id"), + it.getBoolean("out_pending") ) } } @@ -166,7 +228,7 @@ class PaymentDAO(private val db: Database) { ): IncomingRegistrationResult.Success = db.serializable( """ SELECT out_found, out_completed, out_tx_id, out_bounce_id - FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL) + FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL,NULL) """ ) { bind(payment.amount) @@ -183,6 +245,7 @@ class PaymentDAO(private val db: Database) { !it.getBoolean("out_found"), it.getBoolean("out_completed"), it.getString("out_bounce_id"), + false ) } } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt @@ -0,0 +1,82 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.db + +import tech.libeufin.common.* +import tech.libeufin.common.db.* +import java.time.Instant + +/** Data access logic for transfer specific logic */ +class TransferDAO(private val db: Database) { + /** Result of prepared transfer registration */ + sealed interface RegistrationResult { + data object Success: RegistrationResult + data object ReservePubReuse: RegistrationResult + data object SubjectReuse: RegistrationResult + } + + /** Register a prepared transfer */ + suspend fun register( + type: TransferType, + accountPub: EddsaPublicKey, + authPub: EddsaPublicKey, + recurrent: Boolean, + referenceNumber: String, + timestamp: Instant + ): RegistrationResult = db.serializable( + """ + SELECT + out_subject_reuse + ,out_reserve_pub_reuse + FROM register_prepared_transfers ( + ?::taler_incoming_type, ?, ?, ?, ?, ? + ) + """ + ) { + bind(type) + bind(accountPub) + bind(authPub) + bind(recurrent) + bind(referenceNumber) + bind(timestamp) + one { + when { + it.getBoolean("out_subject_reuse") -> RegistrationResult.SubjectReuse + it.getBoolean("out_reserve_pub_reuse") -> RegistrationResult.ReservePubReuse + else -> RegistrationResult.Success + } + } + } + + /** Unregister a prepared transfer */ + suspend fun unregister( + authorizationPub: EddsaPublicKey, + timestamp: Instant + ) = db.serializable( + "SELECT out_found FROM delete_prepared_transfers(?,?)", + { + bind(authorizationPub) + bind(timestamp) + one { + it.getBoolean(1) + } + } + ) +} +\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/DatabaseTest.kt b/libeufin-nexus/src/test/kotlin/DatabaseTest.kt @@ -27,6 +27,7 @@ import tech.libeufin.nexus.cli.* import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.PaymentDAO.* import tech.libeufin.nexus.db.InitiatedDAO.* +import tech.libeufin.nexus.db.TransferDAO.* import tech.libeufin.ebics.* import java.time.Instant import java.util.UUID; @@ -61,6 +62,45 @@ suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializ } } +sealed interface Status { + data object Simple : Status + data object Pending : Status + data object Bounced : Status + data object Incomplete : Status + data class Reserve(val key: EddsaPublicKey) : Status + data class Kyc(val key: EddsaPublicKey) : Status +} + +suspend fun Database.checkIn(vararg expected: Status) { + val current = this.serializable( + """ + SELECT authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type, metadata + FROM incoming_transactions + LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN bounced_transactions USING (incoming_transaction_id) + ORDER BY incoming_transaction_id + """ + ) { + all { + if (it.getBoolean(1)) { + Status.Pending + } else if (it.getBoolean(2)) { + Status.Bounced + } else if (it.getBoolean(3)) { + Status.Incomplete + } else { + when (it.getOptEnum<TransferType>(4)) { + null -> Status.Simple + TransferType.reserve -> Status.Reserve(EddsaPublicKey(it.getBytes(5))) + TransferType.kyc -> Status.Kyc(EddsaPublicKey(it.getBytes(5))) + } + } + }.toList() + } + assertContentEquals(listOf(*expected), current) +} + class OutgoingPaymentsTest { @Test fun register() = setup { db, _ -> @@ -237,73 +277,242 @@ class IncomingPaymentsTest { // Register val incoming = genInPay(subject) registerIncomingPayment(db, cfg, incoming) - db.checkInCount(1, 1, 0) + db.checkIn(Status.Bounced) // Idempotent registerIncomingPayment(db, cfg, incoming) - db.checkInCount(1, 1, 0) + db.checkIn(Status.Bounced) // No key reuse registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) registerIncomingPayment(db, cfg, genInPay("another $subject")) - db.checkInCount(3, 3, 0) + db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced) // Admin balance adjust is ignored registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) - db.checkInCount(4, 3, 0) + db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple) val original = genInPay("test 2") val incomplete = original.copy(subject = null, debtor = null) // Register incomplete transaction registerIncomingPayment(db, cfg, incomplete) - db.checkInCount(5, 3, 0) + db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete) // Idempotent registerIncomingPayment(db, cfg, incomplete) - db.checkInCount(5, 3, 0) + db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete) // Recover info when complete registerIncomingPayment(db, cfg, original) - db.checkInCount(5, 4, 0) + db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Bounced) } // Test creating an incoming reserve taler transaction without and ID and reconcile it later again @Test fun talerable() = setup { db, _ -> val cfg = NexusIngestConfig.default(AccountType.exchange) - val subject = "test with ${EddsaPublicKey.randEdsaKey()} reserve pub" + val key = EddsaPublicKey.randEdsaKey() + val subject = "test with $key reserve pub" // Register val incoming = genInPay(subject) registerIncomingPayment(db, cfg, incoming) - db.checkInCount(1, 0, 1) + db.checkIn(Status.Reserve(key)) // Idempotent registerIncomingPayment(db, cfg, incoming) - db.checkInCount(1, 0, 1) + db.checkIn(Status.Reserve(key)) // Key reuse is bounced registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) registerIncomingPayment(db, cfg, genInPay("another $subject")) - db.checkInCount(3, 2, 1) + db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced) // Admin balance adjust is ignored registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) - db.checkInCount(4, 2, 1) + db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple) - val original = genInPay("test 2 with ${EddsaPublicKey.randEdsaKey()} reserve pub") + val newKey = EddsaPublicKey.randEdsaKey() + val original = genInPay("test 2 with $newKey reserve pub") val incomplete = original.copy(subject = null, debtor = null) // Register incomplete transaction registerIncomingPayment(db, cfg, incomplete) - db.checkInCount(5, 2, 1) + db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete) // Idempotent registerIncomingPayment(db, cfg, incomplete) - db.checkInCount(5, 2, 1) + db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete) // Recover info when complete registerIncomingPayment(db, cfg, original) - db.checkInCount(5, 2, 2) + db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Reserve(newKey)) + } + + // Test creating an mapped reserve taler transaction without and ID and reconcile it later again + @Test + fun mapping() = setup { db, _ -> + val cfg = NexusIngestConfig.default(AccountType.exchange) + val firstKey = EddsaPublicKey.randEdsaKey() + val authPub = EddsaPublicKey.randEdsaKey() + val referenceNumber = subjectFmtQrBill(authPub) + val subject = "test with MAP:$authPub auth pub" + + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = firstKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + + // Register + val incoming = genInPay(subject) + registerIncomingPayment(db, cfg, incoming) + db.checkIn(Status.Reserve(firstKey)) + + // Idempotent + registerIncomingPayment(db, cfg, incoming) + db.checkIn(Status.Reserve(firstKey)) + + // Admin balance adjust is ignored + registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) + db.checkIn(Status.Reserve(firstKey), Status.Simple) + + val original = genInPay("test 2 for $subject") + val incomplete = original.copy(subject = null, debtor = null) + // Register incomplete transaction + registerIncomingPayment(db, cfg, incomplete) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete) + + // Idempotent + registerIncomingPayment(db, cfg, incomplete) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete) + + // Recover info when complete + registerIncomingPayment(db, cfg, original) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Pending) + + val secondKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = secondKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey)) + + // Key reuse is pending + registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) + registerIncomingPayment(db, cfg, genInPay("another $subject")) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Pending, Status.Pending) + + // Finish pending + val thirdKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = thirdKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Reserve(thirdKey), Status.Pending) + } + + // Test creating an mapped reserve taler transaction without and ID and reconcile it later again + @Test + fun reference() = setup { db, _ -> + val cfg = NexusIngestConfig.default(AccountType.exchange) + val firstKey = EddsaPublicKey.randEdsaKey() + val authPub = EddsaPublicKey.randEdsaKey() + val referenceNumber = subjectFmtQrBill(authPub) + + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = firstKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + + // Register + val incoming = genInPay(referenceNumber) + registerIncomingPayment(db, cfg, incoming) + db.checkIn(Status.Reserve(firstKey)) + + // Idempotent + registerIncomingPayment(db, cfg, incoming) + db.checkIn(Status.Reserve(firstKey)) + + // Admin balance adjust is ignored + registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) + db.checkIn(Status.Reserve(firstKey), Status.Simple) + + val original = genInPay(referenceNumber) + val incomplete = original.copy(subject = null, debtor = null) + // Register incomplete transaction + registerIncomingPayment(db, cfg, incomplete) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete) + + // Idempotent + registerIncomingPayment(db, cfg, incomplete) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete) + + // Recover info when complete + registerIncomingPayment(db, cfg, original) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Pending) + + val secondKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = secondKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey)) + + // Key reuse is pending + registerIncomingPayment(db, cfg, genInPay(referenceNumber, "KUDOS:9")) + registerIncomingPayment(db, cfg, genInPay(referenceNumber)) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Pending, Status.Pending) + + // Finish pending + val thirdKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = thirdKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Reserve(thirdKey), Status.Pending) } @Test @@ -401,7 +610,8 @@ class IncomingPaymentsTest { val cfg = NexusIngestConfig.default(AccountType.exchange) // Check we do not bounce already registered talerable transaction - val talerablePayment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub") + val key = EddsaPublicKey.randEdsaKey() + val talerablePayment = genInPay("test with $key reserve pub") registerIncomingPayment(db, cfg, talerablePayment) db.payment.registerMalformedIncoming( talerablePayment, @@ -415,15 +625,16 @@ class IncomingPaymentsTest { registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null)) registerIncomingPayment(db, cfg, talerablePayment) registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null)) - db.checkInCount(1, 0, 1) + db.checkIn(Status.Reserve(key)) // Check we do not register as talerable bounced transaction - val bouncedPayment = genInPay("bounced ${EddsaPublicKey.randEdsaKey()}") + val newKey = EddsaPublicKey.randEdsaKey() + val bouncedPayment = genInPay("bounced $key") registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null)) registerIncomingPayment(db, cfg, bouncedPayment) registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null)) registerIncomingPayment(db, cfg, bouncedPayment) - db.checkInCount(2, 1, 1) + db.checkIn(Status.Reserve(key), Status.Bounced) } } @@ -733,4 +944,318 @@ class EbicsTxTest { assertNull(db.ebics.first()) } +} + +class TransferTest { + suspend fun Database.mapTx(authPub: EddsaPublicKey) = this.payment.registerTalerableIncoming( + genInPay("subject"), IncomingSubject.Map(authPub) + ) + + suspend fun Database.qrTx(reference: String) = this.payment.registerQrBillIncoming( + genInPay(reference), reference + ) + + @Test + fun registration() = setup { db, cfg -> + val now = Instant.now() + val accountPub = EddsaPublicKey.randEdsaKey() + val authPub = EddsaPublicKey.randEdsaKey() + val referenceNumber = subjectFmtQrBill(authPub) + + // Register + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = false + ) + ) + // Idempotent + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = false + ) + ) + + // Reference number reuse + assertEquals( + RegistrationResult.SubjectReuse, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = EddsaPublicKey.randEdsaKey(), + referenceNumber = referenceNumber, + timestamp = now, + recurrent = false + ) + ) + + // Auth pub reuse replace existing one + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = authPub, + referenceNumber = "032847109247158302947510329", + timestamp = now, + recurrent = false + ) + ) + + // Reserve pub reuse + assertEquals( + RegistrationResult.ReservePubReuse, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = EddsaPublicKey.randEdsaKey(), + referenceNumber = "032847109247158302947510330", + timestamp = now, + recurrent = true + ) + ) + + // Non recurrent accept one then bounce + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = accountPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = false + ) + ) + assertEquals( + IncomingRegistrationResult.Success(1, true, false, null, false), + db.mapTx(authPub) + ) + db.checkIn( + Status.Reserve(accountPub) + ) + assertEquals( + IncomingRegistrationResult.MappingReuse, + db.mapTx(authPub) + ) + + // Recurrent accept one and delay + val newKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = newKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = true + ) + ) + assertEquals( + IncomingRegistrationResult.Success(2, true, false, null, false), + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.Success(3, true, false, null, true), + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.Success(4, true, false, null, true), + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.Success(5, true, false, null, true), + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.Success(6, true, false, null, true), + db.mapTx(authPub) + ) + db.checkIn( + Status.Reserve(accountPub), + Status.Reserve(newKey), + Status.Pending, + Status.Pending, + Status.Pending, + Status.Pending + ) + + // Complete pending on recurrent update + val kycKey = EddsaPublicKey.randEdsaKey() + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.kyc, + accountPub = kycKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = true + ) + ) + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = kycKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = true + ) + ) + + db.checkIn( + Status.Reserve(accountPub), + Status.Reserve(newKey), + Status.Kyc(kycKey), + Status.Reserve(kycKey), + Status.Pending, + Status.Pending, + ) + + // Kyc key reuse keep pending ones + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay(fmtIncomingSubject(IncomingType.kyc, kycKey))) + db.checkIn( + Status.Reserve(accountPub), + Status.Reserve(newKey), + Status.Kyc(kycKey), + Status.Reserve(kycKey), + Status.Pending, + Status.Pending, + Status.Kyc(kycKey) + ) + + // Switching to non recurrent cancel pending + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.kyc, + accountPub = kycKey, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = now, + recurrent = false + ) + ) + db.checkIn( + Status.Reserve(accountPub), + Status.Reserve(newKey), + Status.Kyc(kycKey), + Status.Reserve(kycKey), + Status.Bounced, + Status.Bounced, + Status.Kyc(kycKey) + ) + } + + @Test + fun delete() = setup { db, _ -> + val authPub = EddsaPublicKey.randEdsaKey() + val referenceNumber = subjectFmtQrBill(authPub) + val payto = IbanPayto.rand("Sir Florian") + val amount = TalerAmount("KUDOS:2.53") + + // Unknown + assertFalse(db.transfer.unregister(authPub, Instant.now())) + + // Unused + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = authPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = false + ) + ) + assertTrue(db.transfer.unregister(authPub, Instant.now())) + assertFalse(db.transfer.unregister(authPub, Instant.now())) + + assertEquals( + IncomingRegistrationResult.UnknownMapping, + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.UnknownMapping, + db.qrTx(referenceNumber) + ) + + // Register after deletion is idempotent if already known + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = authPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = false + ) + ) + val cfg = NexusIngestConfig.default(AccountType.exchange) + val payment = genInPay(referenceNumber) + assertEquals(IncomingRegistrationResult.Success(1, true, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber)) + assertEquals(IncomingRegistrationResult.Success(1, false, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber)) + db.checkIn(Status.Reserve(authPub)) + assertTrue(db.transfer.unregister(authPub, Instant.now())) + registerIncomingPayment(db, cfg, payment) + assertEquals(IncomingRegistrationResult.Success(1, false, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber)) + db.checkIn(Status.Reserve(authPub)) + + // Test mapped transfers behavior after deletion + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.kyc, + accountPub = authPub, + authPub = authPub, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = true + ) + ) + + // First is registered + assertEquals( + IncomingRegistrationResult.Success(2, true, false, null, false), + db.qrTx(referenceNumber) + ) + // Other are pending + assertEquals( + IncomingRegistrationResult.Success(3, true, false, null, true), + db.qrTx(referenceNumber) + ) + assertEquals( + IncomingRegistrationResult.Success(4, true, false, null, true), + db.mapTx(authPub) + ) + db.checkIn(Status.Reserve(authPub), Status.Kyc(authPub), Status.Pending, Status.Pending) + + assertTrue(db.transfer.unregister(authPub, Instant.now())) + db.checkIn(Status.Reserve(authPub), Status.Kyc(authPub), Status.Bounced, Status.Bounced) + + assertEquals( + IncomingRegistrationResult.UnknownMapping, + db.mapTx(authPub) + ) + assertEquals( + IncomingRegistrationResult.UnknownMapping, + db.qrTx(referenceNumber) + ) + } } \ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -329,11 +329,11 @@ class WireGatewayApiTest { val (path, key) = when (type) { IncomingType.reserve -> Pair("add-incoming", "reserve_pub") IncomingType.kyc -> Pair("add-kycauth", "account_pub") - IncomingType.wad -> throw UnsupportedOperationException() + IncomingType.map -> throw UnsupportedOperationException() } val valid_req = obj { "amount" to "CHF:44" - key to EddsaPublicKey.rand() + key to EddsaPublicKey.randEdsaKey() "debit_account" to grothoffPayto } diff --git a/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt b/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt @@ -0,0 +1,146 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin 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 Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.cli.registerOutgoingPayment +import tech.libeufin.ebics.randEbicsId +import java.time.Instant +import kotlin.test.* + +class WireTransferApiTest { + // GET /taler-wire-transfer/config + @Test + fun config() = serverSetup { + client.get("/taler-wire-transfer/config").assertOkJson<WireTransferConfig>() + } + + // POST /taler-wire-transfer/registration + @Test + fun registration() = serverSetup { + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + val amount = TalerAmount("KUDOS:55") + val valid_req = obj { + "credit_amount" to amount + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + + val subjects = listOf( + TransferSubject.QrBill(subjectFmtQrBill(pub), amount), + TransferSubject.Simple("Taler MAP:$pub", amount), + ) + + // Valid + client.post("/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, subjects) + } + + // Idempotent + client.post("/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, subjects) + } + + // Bad signature + client.post("/taler-wire-transfer/registration") { + json(valid_req) { + "authorization_signature" to EddsaSignature.rand() + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + } + + // DELETE /taler-wire-transfer/registration + @Test + fun unregister() = serverSetup { + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + + // Unknown + client.delete("/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Know + client.post("/taler-wire-transfer/registration") { + json { + "credit_amount" to "KUDOS:55" + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + }.assertOkJson<SubjectResult>() + client.delete("/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNoContent() + + // Idempotent + client.delete("/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Bad signature + client.delete("/taler-wire-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Old timestamp + client.delete("/taler-wire-transfer/registration") { + val now = Instant.now().minusSeconds(1000000).toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) + } +} +\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/bench.kt b/libeufin-nexus/src/test/kotlin/bench.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -21,11 +21,13 @@ import org.junit.Test import org.postgresql.jdbc.PgConnection import io.ktor.client.request.* import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.test.* import tech.libeufin.nexus.* import tech.libeufin.nexus.cli.* import kotlin.math.max import java.util.UUID; +import java.time.Instant class Bench { @@ -35,6 +37,8 @@ class Bench { val token32 = ByteArray(32) val token64 = ByteArray(64) + val accountPubs = List(amount*2) { EddsaPublicKey.randEdsaKey() } + conn.genData(amount, sequenceOf( "incoming_transactions(amount, subject, execution_time, debit_payto, uetr, tx_id, acct_svcr_ref)" to { val subject = if (it % 4 == 0) null else "subject ${it}" @@ -66,6 +70,18 @@ class Bench { "initiated_outgoing_transactions(amount, subject, initiation_time, credit_payto, outgoing_transaction_id, end_to_end_id)" to { "(42,0)\tsubject\t0\tcredit_payto\t${it*2}\tE2E_ID$it\n" }, + "prepared_transfers(type, account_pub, authorization_pub, recurrent, reference_number, registered_at, incoming_transaction_id)" to { + val type = if (it % 2 == 0) "reserve" else "kyc" + val recurrent = if (it % 3 == 0) "true" else "false" + val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}" + val reference_number = subjectFmtQrBill(accountPubs[it]) + val hex = accountPubs[it].raw.encodeHex() + "$type\t\\\\x$hex\t\\\\x$hex\t$recurrent\t$reference_number\t0\t$incoming_transaction_id\n" + }, + "pending_recurrent_incoming_transactions(incoming_transaction_id, authorization_pub)" to { + val hex = accountPubs[it].raw.encodeHex() + "${it*2}\t\\\\x$hex\n" + }, "bounced_transactions(incoming_transaction_id, initiated_outgoing_transaction_id)" to { if (it % 10 == 0) { "${it/2}\t${it/3}\n" @@ -101,6 +117,8 @@ class Bench { // Generate data db.conn { genData(it, AMOUNT) } + val accountPubs = List(AMOUNT) { EddsaPublicKey.randEdsaKeyPair() } + // Warm HTTP client client.getA("/taler-revenue/config").assertOk() @@ -157,7 +175,7 @@ class Bench { client.postA("/taler-wire-gateway/admin/add-incoming") { json { "amount" to "CHF:0.0001" - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to grothoffPayto } }.assertOk() @@ -171,11 +189,46 @@ class Bench { .assertOk() } + // Wire transfer + measureAction("wt_register") { + val (priv, pub) = accountPubs[it] + val valid_req = obj { + "credit_amount" to "KUDOS:55" + "type" to "reserve" + "alg" to "ECDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + client.post("/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult>() + client.post("/taler-wire-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult>() + } + measureAction("wt_unregister") { + val (priv, pub) = accountPubs[it] + val now = Instant.now().toString() + val valid_req = obj { + "timestamp" to now + "authorization_pub" to pub + "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + client.delete("/taler-wire-transfer/registration") { + json(valid_req) + }.assertNoContent() + client.delete("/taler-wire-transfer/registration") { + json(valid_req) + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + // Observability - measureAction("metrics") { + /*measureAction("metrics") { client.get("/taler-observability/metrics") .assertOk() - } + }*/ }} } } \ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -128,7 +128,7 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { client.postA("/taler-wire-gateway/admin/add-incoming") { json { "amount" to TalerAmount(amount) - "reserve_pub" to EddsaPublicKey.rand() + "reserve_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to grothoffPayto } }.assertOk() @@ -139,7 +139,7 @@ suspend fun ApplicationTestBuilder.addKyc(amount: String) { client.postA("/taler-wire-gateway/admin/add-kycauth") { json { "amount" to TalerAmount(amount) - "account_pub" to EddsaPublicKey.rand() + "account_pub" to EddsaPublicKey.randEdsaKey() "debit_account" to grothoffPayto } }.assertOk() diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -140,6 +140,7 @@ class IntegrationTest { engine?.stop(0, 0) } + @Test fun errors() { val flags = "-c conf/integration.conf -L DEBUG"