commit fa919f1a697e873a0cdd374e7927e6cb7ca826a1
parent c9b1f77872c69a65bfff0144140fb182d1d635d0
Author: Florian Dold <dold@taler.net>
Date: Thu, 3 Sep 2026 13:54:11 +0200
regional-currency: add simulated conversion testing
Diffstat:
10 files changed, 393 insertions(+), 4 deletions(-)
diff --git a/regional-currency-ng/CONVERSION-TESTING.md b/regional-currency-ng/CONVERSION-TESTING.md
@@ -0,0 +1,210 @@
+# Test currency conversion without a fiat bank
+
+This runbook tests a complete cash-in and cash-out cycle while the conversion
+account is not connected to a real bank. It uses a command-line Taler wallet
+and the LibEuFin Nexus testing commands. No EBICS enrollment or payment
+submission is needed.
+
+Only use this mode for a disposable testing deployment. Enabling conversion
+records the fiat currency, IBAN, BIC, and account name as part of the
+deployment identity. The normal playbook will reject later changes to those
+values or an attempt to disable conversion.
+
+## Configure and deploy test mode
+
+Set these values in the target's private inventory file:
+
+```yaml
+regional_currency_testing_deployment: true
+regional_currency_enable_conversion: true
+regional_currency_conversion_test_mode: true
+regional_currency_fiat_currency: CHF
+regional_currency_fiat_bank_name: Synthetic CHF Test Bank
+regional_currency_fiat_account_iban: CH8615624QG38ANY0UVER
+regional_currency_fiat_account_bic: TSTRCHZZXXX
+regional_currency_fiat_account_name: FLODOS Conversion Test
+regional_currency_enable_nexus_services: false
+```
+
+Generate a fresh valid test IBAN instead of copying the example IBAN:
+
+```console
+$ libeufin-nexus testing iban gen --country CH
+CH...
+```
+
+Conversion test mode is valid only when both testing and conversion are
+enabled. It rejects an inventory that enables the Nexus services and configures
+Nexus to require manual acknowledgement before an initiated payment can be
+submitted.
+
+Deploy only the intended host:
+
+```console
+$ ansible -i inventory regio -m ping
+$ ansible-playbook -i inventory site.yml --limit regio
+```
+
+Verify the public configuration from the operator machine:
+
+```console
+$ curl -fsS https://bank.regio.taler.net/config | jq '{currency, allow_conversion}'
+$ curl -fsS https://bank.regio.taler.net/conversion-info/config |
+ jq '{regional_currency, fiat_currency, conversion_rate}'
+$ curl -fsS https://exchange.regio.taler.net/keys |
+ jq '[.accounts[] | {payto_uri, conversion_url, bank_label, priority}]'
+```
+
+The bank must report FLODOS conversion enabled with CHF as the fiat currency.
+The exchange must advertise a priority-10 IBAN account whose conversion URL is
+`https://bank.regio.taler.net/conversion-info/`.
+
+On the managed host, verify that no EBICS worker is running:
+
+```console
+# systemctl is-active libeufin-nexus.target \
+ libeufin-nexus-ebics-fetch.service \
+ libeufin-nexus-ebics-submit.service
+inactive
+inactive
+inactive
+```
+
+## Simulate a conversion withdrawal
+
+On the operator machine, create a temporary wallet and start a manual
+withdrawal. Keep this directory until the whole test has finished:
+
+```console
+$ REGIO_TEST_DIR=$(mktemp -d)
+$ REGIO_WALLET_DB="$REGIO_TEST_DIR/wallet.sqlite3"
+$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \
+ withdraw \
+ --exchange https://exchange.regio.taler.net/ \
+ --amount FLODOS:10 | tee "$REGIO_TEST_DIR/withdraw.out"
+$ REGIO_TX_ID=$(sed -n 's/^transaction //p' "$REGIO_TEST_DIR/withdraw.out")
+$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \
+ transactions show "$REGIO_TX_ID" > "$REGIO_TEST_DIR/withdraw.json"
+$ REGIO_CREDIT_PAYTO=$(jq -r '
+ [.withdrawalDetails.exchangeCreditAccountDetails[]
+ | select(.transferAmount | startswith("CHF:"))
+ | .transferOptions[]
+ | select(.type == "payto")
+ | .paytoUri][0]
+ ' "$REGIO_TEST_DIR/withdraw.json")
+$ printf '%s\n' "$REGIO_CREDIT_PAYTO"
+```
+
+The selected URI includes the synthetic conversion IBAN, the CHF amount, and
+the reserve public key as its transfer subject. Copy the complete URI to the
+managed host and inject it exactly as produced by the wallet:
+
+```console
+# REGIO_CREDIT_PAYTO='payto://iban/...?...'
+# sudo -u libeufin-nexus \
+ libeufin-nexus testing fake-incoming \
+ -c /etc/libeufin/libeufin-nexus.conf \
+ --credit-payto "$REGIO_CREDIT_PAYTO"
+# sudo -u libeufin-nexus \
+ libeufin-nexus testing list incoming \
+ -c /etc/libeufin/libeufin-nexus.conf
+```
+
+Back on the operator machine, wait for the exchange and wallet to process the
+reserve, then check the balance:
+
+```console
+$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \
+ transactions wait "$REGIO_TX_ID" --state final --timeout 2m
+$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" balance --pretty
+```
+
+With the one-to-one rate and zero fees, the wallet balance must be
+`FLODOS:10`.
+
+## Deposit and cash out
+
+Create a dedicated zero-debt bank account on the managed host. Its cash-out
+IBAN must be different from the synthetic conversion account:
+
+```console
+# REGIO_TEST_USERNAME="conversion-test-$(date -u +%Y%m%d%H%M%S)"
+# REGIO_TEST_PASSWORD=$(uuidgen)
+# REGIO_CASHOUT_IBAN=$(libeufin-nexus testing iban gen --country CH)
+# REGIO_ACCOUNT_PAYTO=$(sudo -u libeufin-bank \
+ libeufin-bank create-account \
+ --username "$REGIO_TEST_USERNAME" \
+ --password "$REGIO_TEST_PASSWORD" \
+ --name 'Conversion Test Customer' \
+ --cashout_payto_uri \
+ "payto://iban/TSTRCHZZXXX/$REGIO_CASHOUT_IBAN" \
+ --debit_threshold FLODOS:0)
+# printf 'username: %s\naccount: %s\ncash-out IBAN: %s\n' \
+ "$REGIO_TEST_USERNAME" "$REGIO_ACCOUNT_PAYTO" "$REGIO_CASHOUT_IBAN"
+```
+
+Copy `REGIO_ACCOUNT_PAYTO` to the operator machine and deposit the complete
+wallet balance into it:
+
+```console
+$ REGIO_ACCOUNT_PAYTO='payto://x-taler-bank/...'
+$ taler-wallet-cli --wallet-db "$REGIO_WALLET_DB" \
+ deposit FLODOS:10 "$REGIO_ACCOUNT_PAYTO" --timeout 2m
+```
+
+On the managed host, create a one-hour API token and wait until the account
+shows a `FLODOS:10` credit balance:
+
+```console
+# REGIO_TEST_TOKEN=$(sudo -u libeufin-bank \
+ libeufin-bank create-token \
+ --username "$REGIO_TEST_USERNAME" \
+ --scope readwrite \
+ --duration 3600000000 \
+ --description 'conversion test')
+# curl -fsS \
+ -H "Authorization: Bearer $REGIO_TEST_TOKEN" \
+ "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME" | jq .balance
+```
+
+Ask the bank for the current cash-out quote, then submit exactly that debit and
+credit amount with a fresh idempotency key:
+
+```console
+# REGIO_QUOTE=$(curl -fsS \
+ "https://bank.regio.taler.net/conversion-info/cashout-rate?amount_debit=FLODOS:10")
+# REGIO_CASHOUT_CREDIT=$(printf '%s' "$REGIO_QUOTE" | jq -r .amount_credit)
+# REGIO_REQUEST_UID=$(head -c 32 /dev/urandom | gnunet-base32)
+# jq -n \
+ --arg uid "$REGIO_REQUEST_UID" \
+ --arg debit 'FLODOS:10' \
+ --arg credit "$REGIO_CASHOUT_CREDIT" \
+ --arg subject "regio conversion test $REGIO_TEST_USERNAME" \
+ '{request_uid: $uid, amount_debit: $debit,
+ amount_credit: $credit, subject: $subject}' |
+ curl -fsS \
+ -H "Authorization: Bearer $REGIO_TEST_TOKEN" \
+ -H 'Content-Type: application/json' \
+ --data-binary @- \
+ "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME/cashouts" |
+ jq .
+```
+
+Verify that the bank balance has returned to zero and that Nexus contains the
+CHF payment it would send to the fiat bank:
+
+```console
+# curl -fsS \
+ -H "Authorization: Bearer $REGIO_TEST_TOKEN" \
+ "https://bank.regio.taler.net/accounts/$REGIO_TEST_USERNAME" | jq .balance
+# sudo -u libeufin-nexus \
+ libeufin-nexus testing list initiated \
+ -c /etc/libeufin/libeufin-nexus.conf
+```
+
+The initiated entry must show `CHF:10`, the generated cash-out IBAN, and the
+unique test subject. This queued entry is the proof of the outgoing cash-out;
+there is deliberately no matching outgoing bank statement in simulated mode.
+
+Do not run `libeufin-nexus ebics-submit` or manually acknowledge the transfer.
+Those operations belong to a deployment connected to a real fiat bank.
diff --git a/regional-currency-ng/ChangeLog b/regional-currency-ng/ChangeLog
@@ -1,4 +1,12 @@
Thu Sep 3 2026
+ Added a guarded conversion test mode, a two-host sample inventory, and
+ an operator runbook for simulated cash-in and cash-out testing with a
+ command-line wallet and LibEuFin Nexus testing commands.
+ Use the regional currency for IBAN wire fees, as required by the
+ exchange even when the account receives a fiat conversion payment.
+ Install the LibEuFin conversion database link that turns Nexus fiat
+ incoming payments into regional-currency exchange credits.
+
Added a guarded playbook that completely resets and reprovisions an
exchange on testing deployments.
diff --git a/regional-currency-ng/README.md b/regional-currency-ng/README.md
@@ -27,6 +27,19 @@ $ cd regional-currency-ng
$ ./inventory-wizard.py
```
+Alternatively, copy `inventory.example.yml`, remove the host example that does
+not apply, and adjust every host, domain, contact, and password value:
+
+```console
+$ cp inventory.example.yml inventory/HOST.yml
+$ chmod 0600 inventory/HOST.yml
+$ editor inventory/HOST.yml
+```
+
+The example contains one normal deployment without conversion and one testing
+deployment with simulated CHF conversion. Do not leave both example hosts in a
+live inventory unless both are intentional targets.
+
`./main.sh` is retained as a compatibility alias for the wizard. The wizard
only asks questions and creates one ordinary Ansible inventory file for each
target:
@@ -134,6 +147,12 @@ role first checks one transient fetch and submit before enabling the continuous
Nexus services. Setting the variable back to `false` stops and disables them.
The inventory wizard asks about this switch only for conversion deployments.
+For a conversion deployment without a real fiat bank connection, set
+`regional_currency_conversion_test_mode: true`. This is restricted to testing
+deployments, keeps the Nexus workers disabled, and retains initiated cash-out
+payments for inspection. See [CONVERSION-TESTING.md](CONVERSION-TESTING.md) for
+the complete command-line wallet, simulated cash-in, and cash-out runbook.
+
The nginx proxy limits token creation to 30 requests per minute per client IP
and challenge requests to 10 requests per minute by default. Operators can
tune `regional_currency_bank_token_rate`,
diff --git a/regional-currency-ng/inventory-wizard.py b/regional-currency-ng/inventory-wizard.py
@@ -366,11 +366,22 @@ def collect_configuration(
),
}
)
- variables["regional_currency_enable_nexus_services"] = ask_yes_no(
- "Enable Nexus services (only after EBICS enrollment is complete)",
- bool_value(variables.get("regional_currency_enable_nexus_services"), False),
+ conversion_test_mode = testing and ask_yes_no(
+ "Use simulated conversion test mode (no EBICS bank connection)",
+ bool_value(variables.get("regional_currency_conversion_test_mode"), False),
)
+ variables["regional_currency_conversion_test_mode"] = conversion_test_mode
+ if conversion_test_mode:
+ variables["regional_currency_enable_nexus_services"] = False
+ else:
+ variables["regional_currency_enable_nexus_services"] = ask_yes_no(
+ "Enable Nexus services (only after EBICS enrollment is complete)",
+ bool_value(
+ variables.get("regional_currency_enable_nexus_services"), False
+ ),
+ )
else:
+ variables["regional_currency_conversion_test_mode"] = False
variables["regional_currency_enable_nexus_services"] = False
telesign = ask_yes_no(
diff --git a/regional-currency-ng/inventory.example.yml b/regional-currency-ng/inventory.example.yml
@@ -0,0 +1,45 @@
+---
+all:
+ children:
+ regional_currency:
+ hosts:
+ normal-example:
+ ansible_host: normal.example.net
+ ansible_user: root
+ ansible_port: 22
+ ansible_become: true
+ regional_currency_currency: NETZBON
+ regional_currency_domain: normal.example.net
+ regional_currency_bank_name: Example Regional Bank
+ regional_currency_bank_admin_password: CHANGE-ME
+ regional_currency_testing_deployment: false
+ regional_currency_enable_tls: true
+ regional_currency_tls_email: operator@example.net
+ regional_currency_enable_conversion: false
+ regional_currency_conversion_test_mode: false
+ regional_currency_enable_nexus_services: false
+ regional_currency_apt_testing: false
+
+ conversion-test-example:
+ ansible_host: conversion-test.example.net
+ ansible_user: root
+ ansible_port: 22
+ ansible_become: true
+ regional_currency_currency: FLODOS
+ regional_currency_domain: conversion-test.example.net
+ regional_currency_bank_name: Example Test Bank
+ regional_currency_bank_admin_password: CHANGE-ME
+ regional_currency_testing_deployment: true
+ regional_currency_enable_tls: true
+ regional_currency_tls_email: operator@example.net
+ regional_currency_enable_conversion: true
+ regional_currency_conversion_test_mode: true
+ regional_currency_fiat_currency: CHF
+ regional_currency_fiat_bank_name: Synthetic CHF Test Bank
+ # Generate a fresh value with:
+ # libeufin-nexus testing iban gen --country CH
+ regional_currency_fiat_account_iban: CH8615624QG38ANY0UVER
+ regional_currency_fiat_account_bic: TSTRCHZZXXX
+ regional_currency_fiat_account_name: FLODOS Conversion Test
+ regional_currency_enable_nexus_services: false
+ regional_currency_apt_testing: true
diff --git a/regional-currency-ng/roles/regional_currency/defaults/main.yml b/regional-currency-ng/roles/regional_currency/defaults/main.yml
@@ -7,6 +7,7 @@ regional_currency_bank_port: 8080
regional_currency_enable_landing_page: true
regional_currency_testing_deployment: false
regional_currency_enable_conversion: false
+regional_currency_conversion_test_mode: false
regional_currency_fiat_currency: CHF
regional_currency_fiat_bank_name: ""
regional_currency_fiat_account_iban: ""
diff --git a/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml b/regional-currency-ng/roles/regional_currency/tasks/libeufin.yml
@@ -18,6 +18,20 @@
when: regional_currency_enable_conversion
changed_when: false
+- name: Configure Nexus payment acknowledgement policy
+ ansible.builtin.command:
+ argv:
+ - taler-harness
+ - config
+ - -c
+ - /etc/libeufin/libeufin-nexus.conf
+ - set
+ - nexus-submit
+ - MANUAL_ACK
+ - "{{ 'YES' if regional_currency_conversion_test_mode else 'NO' }}"
+ when: regional_currency_enable_conversion
+ changed_when: false
+
- name: Install LibEuFin bank configuration
ansible.builtin.template:
src: libeufin-bank.conf.j2
@@ -51,6 +65,20 @@
chdir: /
changed_when: false
+- name: Link Nexus fiat payments to regional-currency cash-in
+ become: true
+ become_user: postgres
+ ansible.builtin.command:
+ argv:
+ - psql
+ - --dbname=libeufin
+ - --set=ON_ERROR_STOP=1
+ - --file=/usr/share/libeufin/sql/libeufin-conversion-setup.sql
+ args:
+ chdir: /
+ when: regional_currency_enable_conversion
+ changed_when: false
+
- name: Enable and start LibEuFin bank
ansible.builtin.service:
name: libeufin-bank
diff --git a/regional-currency-ng/roles/regional_currency/tasks/main.yml b/regional-currency-ng/roles/regional_currency/tasks/main.yml
@@ -14,6 +14,9 @@
- not regional_currency_enable_conversion or regional_currency_fiat_account_name | length > 0
- not regional_currency_enable_telesign or regional_currency_telesign_auth_token | length > 0
- not regional_currency_enable_nexus_services or regional_currency_enable_conversion
+ - not regional_currency_conversion_test_mode or regional_currency_testing_deployment
+ - not regional_currency_conversion_test_mode or regional_currency_enable_conversion
+ - not regional_currency_conversion_test_mode or not regional_currency_enable_nexus_services
- regional_currency_bank_token_rate is match('^[1-9][0-9]*r/[smh]$')
- (regional_currency_bank_token_burst | int) > 0
- regional_currency_bank_challenge_rate is match('^[1-9][0-9]*r/[smh]$')
diff --git a/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.service.j2 b/regional-currency-ng/roles/regional_currency/templates/taler-exchange-offline.service.j2
@@ -12,4 +12,4 @@ StateDirectory=taler-exchange-offline-maintenance
PrivateDevices=yes
PrivateTmp=yes
ProtectSystem=full
-ExecStart=/bin/bash -ec 'year=$$(date -u +%%Y); next_year=$$((year + 1)); marker="v1:$$year:x-taler-bank{% if regional_currency_enable_conversion %},iban{% endif %}:1h:6a:0"; set -- download sign; update_marker=no; if test "$$(cat "$$STATE_DIRECTORY/fee-year" 2>/dev/null || true)" != "$$marker"; then set -- "$$@" wire-fee "$$year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" wire-fee "$$next_year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0"{% if regional_currency_enable_conversion %} wire-fee "$$year" iban "{{ regional_currency_fiat_currency }}:0" "{{ regional_currency_fiat_currency }}:0" wire-fee "$$next_year" iban "{{ regional_currency_fiat_currency }}:0" "{{ regional_currency_fiat_currency }}:0"{% endif %} global-fee "$$year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0 global-fee "$$next_year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0; update_marker=yes; fi; /usr/bin/taler-exchange-offline "$$@" upload; if test "$$update_marker" = yes; then echo "$$marker" > "$$STATE_DIRECTORY/fee-year"; fi'
+ExecStart=/bin/bash -ec 'year=$$(date -u +%%Y); next_year=$$((year + 1)); marker="v2:$$year:x-taler-bank{% if regional_currency_enable_conversion %},iban{% endif %}:1h:6a:0"; set -- download sign; update_marker=no; if test "$$(cat "$$STATE_DIRECTORY/fee-year" 2>/dev/null || true)" != "$$marker"; then set -- "$$@" wire-fee "$$year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" wire-fee "$$next_year" x-taler-bank "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0"{% if regional_currency_enable_conversion %} wire-fee "$$year" iban "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" wire-fee "$$next_year" iban "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0"{% endif %} global-fee "$$year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0 global-fee "$$next_year" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" "{{ regional_currency_currency }}:0" 1h 6a 0; update_marker=yes; fi; /usr/bin/taler-exchange-offline "$$@" upload; if test "$$update_marker" = yes; then echo "$$marker" > "$$STATE_DIRECTORY/fee-year"; fi'
diff --git a/regional-currency-ng/tests/test_inventory_wizard.py b/regional-currency-ng/tests/test_inventory_wizard.py
@@ -62,5 +62,69 @@ class InventoryWriteTests(unittest.TestCase):
self.assertEqual(list(directory.glob(".target.yml.*")), [])
+class InventoryPromptTests(unittest.TestCase):
+ def test_conversion_test_mode_keeps_nexus_services_disabled(self):
+ answers = {
+ "SSH host name or address": "conversion.example",
+ "SSH user": "root",
+ "Base domain for the service hosts and optional landing page": "conversion.example",
+ "Human-readable bank name": "Test Bank",
+ "Fiat bank name": "Synthetic Bank",
+ "Fiat account legal name": "Synthetic Account",
+ }
+ matching_answers = {
+ "Inventory host name to add or update": "conversion-test",
+ "Regional currency code": "FLODOS",
+ "Fiat currency code": "CHF",
+ "Fiat account IBAN": "CH8615624QG38ANY0UVER",
+ "Fiat account BIC": "TSTRCHZZXXX",
+ }
+ boolean_answers = {
+ "Configure a testing deployment": True,
+ "Configure a landing page on the base domain": True,
+ "Obtain TLS certificates using Let's Encrypt": False,
+ "Configure conversion to a fiat currency": True,
+ "Use simulated conversion test mode (no EBICS bank connection)": True,
+ "Configure Telesign SMS authentication": False,
+ "Configure exchange terms of service": False,
+ "Configure an exchange privacy policy": False,
+ "Use the GNU Taler testing APT repository (Debian only)": True,
+ }
+
+ def ask(message, default=None, **_kwargs):
+ return answers.get(message, default)
+
+ def ask_matching(message, _pattern, default=None, **_kwargs):
+ return matching_answers.get(message, default)
+
+ def ask_yes_no(message, _default):
+ if message.startswith("Enable Nexus services"):
+ self.fail("test mode must not offer to enable Nexus services")
+ return boolean_answers[message]
+
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ with (
+ mock.patch.object(inventory_wizard, "ask", side_effect=ask),
+ mock.patch.object(
+ inventory_wizard, "ask_matching", side_effect=ask_matching
+ ),
+ mock.patch.object(
+ inventory_wizard, "ask_yes_no", side_effect=ask_yes_no
+ ),
+ mock.patch.object(
+ inventory_wizard, "ask_int", side_effect=lambda _m, d: d
+ ),
+ mock.patch.object(
+ inventory_wizard.getpass, "getpass", return_value="secret"
+ ),
+ ):
+ _, _, variables, _, _, _ = inventory_wizard.collect_configuration(
+ Path(temporary_directory)
+ )
+
+ self.assertTrue(variables["regional_currency_conversion_test_mode"])
+ self.assertFalse(variables["regional_currency_enable_nexus_services"])
+
+
if __name__ == "__main__":
unittest.main()