anastasis

Credential backup and recovery protocol and service
Log | Files | Refs | Submodules | README | LICENSE

commit 1f8c8ce04a552a6352cb8074d769a9a70e8fadb2
parent d71302673998390b23a53e457637712dcf7a6088
Author: Christian Grothoff <christian@grothoff.org>
Date:   Fri,  7 Aug 2026 10:59:44 +0200

add multi-currency support

Diffstat:
Mcontrib/ci/jobs/2-test/3-startdb.sh | 13++++++++++---
Mcontrib/redux.countries.json | 45++++++++++++++++++++++++++++++---------------
Mdoc/anastasis.conf.5 | 24++++++++++++++++++++++--
Mdoc/sphinx/reducer.rst | 48+++++++++++++++++++++++++++++++++++++++++++++---
Mdoc/sphinx/rest.rst | 39+++++++++++++++++++++++++++++++++++----
Msrc/authorization/anastasis_authorization_plugin.c | 17+++++++++++------
Msrc/backend/anastasis-httpd.c | 473++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/backend/anastasis-httpd.h | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/backend/anastasis-httpd_config.c | 38+++++++++++++++++++++++++++++++-------
Msrc/backend/anastasis-httpd_policy-upload.c | 87++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/backend/anastasis-httpd_truth-challenge.c | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/backend/anastasis-httpd_truth-solve.c | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/backend/anastasis-httpd_truth-upload.c | 85+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Msrc/backend/anastasis.conf | 37+++++++++++++++++++++++++++++++++----
Msrc/cli/meson.build | 1+
Msrc/cli/setup.sh | 21++++++++++++++++++++-
Asrc/cli/test_anastasis_config_currencies.sh | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/cli/test_free_reducer.conf | 3+++
Msrc/cli/test_reducer.conf | 1+
Msrc/include/anastasis/anastasis-database/get_challenge_payment.h | 6+++++-
Asrc/include/anastasis/anastasis-database/update_auth_iban_in_currency.h | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/anastasis/anastasis-database/update_to_challenge_payment_paid.h | 4+++-
Msrc/include/anastasis_authorization_plugin.h | 9++++++---
Msrc/include/anastasis_database_lib.h | 1+
Msrc/include/anastasis_service.h | 49+++++++++++++++++++++++++++++++++++++++++++++----
Msrc/reducer/anastasis_api_backup_redux.c | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/reducer/anastasis_api_redux.c | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/reducer/anastasis_api_redux_free.c | 10++++++++++
Msrc/reducer/anastasis_api_redux_parse.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/reducer/anastasis_api_redux_serialize.c | 72++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/reducer/anastasis_api_redux_state.h | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/restclient/anastasis_api_config.c | 389++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/stasis/anastasis-db_pg.h | 6+++++-
Msrc/stasis/anastasis-dbinit.c | 20++++++++++++++++++++
Msrc/stasis/do_insert_recdoc_payment.c | 4++--
Msrc/stasis/do_insert_recdoc_payment.sql | 2+-
Msrc/stasis/get_challenge_payment.c | 12+++++++++++-
Msrc/stasis/get_pending_challenge_payment.c | 1-
Msrc/stasis/get_recdoc_payment.c | 1-
Msrc/stasis/insert_auth_iban_in.c | 4++--
Msrc/stasis/insert_challenge_payment.c | 4++--
Msrc/stasis/insert_truth_payment.c | 4++--
Msrc/stasis/iterate_auth_iban_transfers.c | 5++---
Msrc/stasis/meson.build | 2++
Asrc/stasis/stasis-0004.sql | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/stasis/test_anastasis_db.c | 59+++++++++++++++++++++++++++++++++++++++--------------------
Asrc/stasis/update_auth_iban_in_currency.c | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/stasis/update_to_challenge_payment_paid.c | 13+++++++++++--
Msrc/testing/test_anastasis.c | 42++++++++++++++++++++++++++----------------
Msrc/testing/test_anastasis_api.c | 42++++++++++++++++++++++++++----------------
50 files changed, 2360 insertions(+), 477 deletions(-)

diff --git a/contrib/ci/jobs/2-test/3-startdb.sh b/contrib/ci/jobs/2-test/3-startdb.sh @@ -12,9 +12,16 @@ sudo -u postgres "${PG_BIN}/pg_ctl" \ sleep 10 sudo -u postgres createuser -p "$PGPORT" root -s -w -# 'talercheck' is used by the exchange, the merchant and libeufin-bank -# that taler-unified-setup.sh brings up for the reducer and IBAN tests. -sudo -u postgres createdb -p "$PGPORT" -O root talercheck +# 'anastasistalercheck' is used by the exchange, the merchant and +# libeufin-bank that taler-unified-setup.sh brings up for the reducer and +# IBAN tests. taler-unified-setup.sh runs every one of those dbinits with +# --reset, so the name must NOT be shared with another project's test +# suite: the plain 'talercheck' that the exchange, merchant, sync and donau +# suites all use gets its schema dropped out from under a running exchange +# whenever two of those suites overlap on one host, which shows up here as +# spurious 500s ('relation "denominations" does not exist') from +# taler-exchange-offline. +sudo -u postgres createdb -p "$PGPORT" -O root anastasistalercheck # The provider database: 'anastasischeck' for the stasis and testing # suites, 'anastasischeck1' .. 'anastasischeck4' for the four providers # the reducer/CLI tests run against (see src/cli/test_anastasis_reducer_*.conf). diff --git a/contrib/redux.countries.json b/contrib/redux.countries.json @@ -6,91 +6,106 @@ "code" : "al", "name" : "Albania", "continent" : "Europe", - "call_code" : "+355" + "call_code" : "+355", + "currency" : "ALL" }, { "code" : "be", "name" : "Belgium", "continent" : "Europe", - "call_code" : "+32" + "call_code" : "+32", + "currency" : "EUR" }, { "code" : "ch", "name" : "Switzerland", "continent" : "Europe", - "call_code" : "+41" + "call_code" : "+41", + "currency" : "CHF" }, { "code" : "cz", "name" : "Czech Republic", "continent" : "Europe", - "call_code" : "+420" + "call_code" : "+420", + "currency" : "CZK" }, { "code" : "de", "name" : "Germany", "continent" : "Europe", - "call_code" : "+49" + "call_code" : "+49", + "currency" : "EUR" }, { "code" : "dk", "name" : "Denmark", "continent" : "Europe", - "call_code" : "+45" + "call_code" : "+45", + "currency" : "DKK" }, { "code" : "es", "name" : "Spain", "continent" : "Europe", - "call_code" : "+44" + "call_code" : "+44", + "currency" : "EUR" }, { "code" : "fr", "name" : "France", "continent" : "Europe", - "call_code" : "+33" + "call_code" : "+33", + "currency" : "EUR" }, { "code" : "in", "name" : "India", "continent" : "India", - "call_code" : "+91" + "call_code" : "+91", + "currency" : "INR" }, { "code" : "it", "name" : "Italy", "continent" : "Europe", - "call_code" : "+39" + "call_code" : "+39", + "currency" : "EUR" }, { "code" : "jp", "name" : "Japan", "continent" : "Asia", - "call_code" : "+81" + "call_code" : "+81", + "currency" : "JPY" }, { "code" : "nl", "name" : "Netherlands", "continent" : "Europe", - "call_code" : "+31" + "call_code" : "+31", + "currency" : "EUR" }, { "code" : "sk", "name" : "Slovakia", "continent" : "Europe", - "call_code" : "+421" + "call_code" : "+421", + "currency" : "EUR" }, { "code" : "us", "name" : "United States of America (USA)", "continent" : "North America", - "call_code" : "+1" + "call_code" : "+1", + "currency" : "USD" }, { "code" : "xx", "name" : "Testland", "continent" : "Demoworld", - "call_code" : "+0" + "call_code" : "+0", + "currency" : "TESTKUDOS" } ] } diff --git a/doc/anastasis.conf.5 b/doc/anastasis.conf.5 @@ -64,6 +64,13 @@ Values that represent an amount are in the usual amount syntax: \fBCURRENCY:VALUE.FRACTION\fP, e.g. \fBEUR:1.50\fP\&. The \fBFRACTION\fP portion may extend up to 8 places. .sp +Values that represent a price may give one amount per currency, separated +by ";", e.g. \fBEUR:1.50;CHF:1.50;USD:1.80\fP\&. Within one such option +either every amount is zero (the option is free) or none is: charging in +one currency and not in another does not price the service, it prices the +user\(aqs choice of currency, and \fBanastasis\-httpd\fP refuses to start on +it. An empty value means the option is free. +.sp Files containing default values for many of the options described below are installed under \fB$ANASTASIS_PREFIX/share/taler/config.d/\fP\&. The configuration file given with \fB\-c\fP to Anastasis binaries @@ -79,11 +86,24 @@ The following options are from the \fB[anastasis]\fP section and used by the \fBanastasis\-httpd\fP service. .INDENT 0.0 .TP +.B CURRENCIES +Currencies the service is priced in, separated by ";", i.e. +"EUR;CHF;USD". The first entry is the primary currency, which is what +the scalar fields of the \fB/config\fP response report for the benefit of +clients that predate multi\-currency support. Every priced option must +give a price in exactly these currencies, or \fBanastasis\-httpd\fP refuses +to start: without that check a user could discover during recovery that +some step is unavailable in the currency they hold, which is the worst +possible moment. May be omitted by a provider pricing in a single +currency, in which case it is derived from the fees themselves. +.TP .B ANNUAL_FEE -Annual fee to be paid for policy uploads, i.e. "EUR:1.5". +Annual fee to be paid for policy uploads, one amount per currency in +\fBCURRENCIES\fP, i.e. "EUR:1.5;CHF:1.5;USD:1.8". .TP .B TRUTH_UPLOAD_FEE -Annual fee to be paid for truth uploads, i.e. "EUR:1.5". +Annual fee to be paid for truth uploads, one amount per currency, i.e. +"EUR:1.5;CHF:1.5;USD:1.8". .TP .B DB Database backend to use, only \fBpostgres\fP is supported right now. diff --git a/doc/sphinx/reducer.rst b/doc/sphinx/reducer.rst @@ -1057,9 +1057,51 @@ The reducer will simply transition to the ``SECRET_EDITING`` state: } Here, ``upload_fees`` is an array of applicable upload fees for the -given policy expiration time. This is an array because fees could -be in different currencies. The final cost may be lower if the -user already paid for some of the time. +given policy expiration time. It is advisory: the orders the providers +create carry *every* currency they accept as a payable choice, and the +wallet is free to settle in one of the others. + +Each provider contributes exactly one amount to this total --- in +``preferred_currency`` if it accepts that currency, and otherwise in its +own primary currency. It is therefore an array, but it collapses to a +single entry whenever every provider involved accepts the preferred +currency. More than one entry means the backup will be settled as more +than one order in more than one currency, which is accurate: each +provider is a separate order at a separate merchant backend. + +The state also carries ``currencies``, the currencies that *all* of the +providers used by this backup accept, i.e. the ones in which the whole +backup can be paid for with a single choice of currency. It may be +empty, which is not an error. + +The final cost may be lower if the user already paid for some of the +time. + + +**select_currency:** + +Selects the currency the user would rather pay in. Valid wherever +``select_country`` is, and also while user attributes are being +collected. + +Arguments (example): + +.. code-block:: json + + { + "currency": "CHF" + } + +The choice is advisory, as described above for ``upload_fees``: it +decides which currency totals are quoted in and which one a user +interface puts in front of the user, not what the wallet must pay in. +It is not checked against the providers, because when the currency is +usually chosen their ``/config`` requests are still in flight, and a +currency that no provider accepts is not an error either --- it just +means no single-currency total can be quoted. + +If it is never set, it defaults to the ``currency`` of the selected +country. If the array of ``policies`` is currently empty, the reducer will return an error response instead of allowing the user to continue. diff --git a/doc/sphinx/rest.rst b/doc/sphinx/rest.rst @@ -436,17 +436,40 @@ Receiving Configuration // Maximum policy upload size supported. storage_limit_in_megabytes: number; - // Payment required to maintain an account to store policy documents for a year. + // Currencies the provider prices its service in, primary currency + // first. Orders created by the provider offer every one of these + // as a payable choice, so the wallet decides which one is used. + // @since protocol **v3**. + currencies?: string[]; + + // Payment required to maintain an account to store policy documents for a year, + // in the provider's *primary* currency. // Users can pay more, in which case the storage time will go up proportionally. annual_fee: Amount; - // Payment required to upload truth. To be paid per upload. + // Payment required to maintain an account for a year, one entry per + // currency in ``currencies``. Empty if the service is free. + // Either every entry is zero or none is: a provider that would charge + // in one currency and not in another is refusing to price its service + // and is rejected by its own configuration check. + // @since protocol **v3**. + annual_fees?: Amount[]; + + // Payment required to upload truth, in the primary currency. To be paid per upload. truth_upload_fee: Amount; + // Payment required to upload truth, one entry per currency. + // @since protocol **v3**. + truth_upload_fees?: Amount[]; + // Limit on the liability that the provider is offering with - // respect to the services provided. + // respect to the services provided, in the primary currency. liability_limit: Amount; + // Limit on the liability, one entry per currency. + // @since protocol **v3**. + liability_limits?: Amount[]; + // Salt value with 128 bits of entropy. // Different providers // will use different high-entropy salt values. The resulting @@ -464,9 +487,17 @@ Receiving Configuration // Name of the authorization method. type: string; - // Fee for accessing key share using this method. + // Fee for accessing key share using this method, in the provider's + // primary currency. cost: Amount; + // Fee for accessing key share using this method, one entry per + // currency in ``currencies``. Empty means the method is free, + // which is what an IBAN-style method wants when the wire transfer + // the user has to make is already the cost of using it. + // @since protocol **v3**. + costs?: Amount[]; + } .. _terms: diff --git a/src/authorization/anastasis_authorization_plugin.c b/src/authorization/anastasis_authorization_plugin.c @@ -84,7 +84,7 @@ ANASTASIS_authorization_plugin_load ( char *sec_name; struct AuthPlugin *ap; char *currency; - struct TALER_Amount cost; + struct TALER_AmountList costs; for (ap = ap_head; NULL != ap; ap = ap->next) if (0 == strcmp (method, @@ -103,11 +103,14 @@ ANASTASIS_authorization_plugin_load ( } ap = GNUNET_new (struct AuthPlugin); ap->ac.cfg = AH_cfg; + /* The list has already been validated at startup by + anastasis-httpd's parse_prices(), so anything wrong here means the + configuration changed underneath us. */ if (GNUNET_OK != - TALER_config_get_amount (AH_cfg, - sec_name, - "COST", - &cost)) + TALER_config_get_amount_list (AH_cfg, + sec_name, + "COST", + &costs)) { GNUNET_log_config_missing (GNUNET_ERROR_TYPE_WARNING, sec_name, @@ -132,10 +135,11 @@ ANASTASIS_authorization_plugin_load ( "Authentication method `%s' not supported\n", method); GNUNET_free (lib_name); + TALER_amount_list_free (&costs); GNUNET_free (ap); return NULL; } - authorization->cost = cost; + authorization->costs = costs; ap->name = GNUNET_strdup (method); ap->lib_name = lib_name; ap->authorization = authorization; @@ -156,6 +160,7 @@ ANASTASIS_authorization_plugin_shutdown (void) GNUNET_CONTAINER_DLL_remove (ap_head, ap_tail, ap); + TALER_amount_list_free (&ap->authorization->costs); GNUNET_PLUGIN_unload (ap->lib_name, ap->authorization); GNUNET_free (ap->lib_name); diff --git a/src/backend/anastasis-httpd.c b/src/backend/anastasis-httpd.c @@ -30,6 +30,7 @@ #include "anastasis-httpd_truth.h" #include "anastasis-httpd_terms.h" #include "anastasis-httpd_config.h" +#include <taler/taler_json_lib.h> /** @@ -38,24 +39,34 @@ unsigned long long int AH_upload_limit_mb; /** - * Annual fee for the backup account. + * Currencies this provider prices its service in, primary currency first. */ -struct TALER_Amount AH_annual_fee; +char **AH_currencies; /** - * Fee for a truth upload. + * Length of the #AH_currencies array. */ -struct TALER_Amount AH_truth_upload_fee; +unsigned int AH_currencies_len; /** - * Amount of insurance. + * Annual fee for the backup account, per currency. */ -struct TALER_Amount AH_insurance; +struct TALER_AmountList AH_annual_fees; /** - * Cost for secure question truth download. + * Fee for a truth upload, per currency. */ -struct TALER_Amount AH_question_cost; +struct TALER_AmountList AH_truth_upload_fees; + +/** + * Amount of insurance, per currency. + */ +struct TALER_AmountList AH_insurance; + +/** + * Cost for secure question truth download, per currency. + */ +struct TALER_AmountList AH_question_costs; /** * Our configuration. @@ -483,6 +494,408 @@ do_shutdown (void *cls) GNUNET_CONTAINER_heap_destroy (AH_to_heap); AH_to_heap = NULL; } + TALER_amount_list_free (&AH_annual_fees); + TALER_amount_list_free (&AH_truth_upload_fees); + TALER_amount_list_free (&AH_insurance); + TALER_amount_list_free (&AH_question_costs); + for (unsigned int i = 0; i<AH_currencies_len; i++) + GNUNET_free (AH_currencies[i]); + GNUNET_array_grow (AH_currencies, + AH_currencies_len, + 0); +} + + +json_t * +AH_make_order (const char *order_id, + const char *summary, + const struct TALER_AmountList *prices) +{ + json_t *choices; + json_t *order; + + GNUNET_assert (0 != prices->tal_len); + choices = json_array (); + GNUNET_assert (NULL != choices); + for (unsigned int i = 0; i<prices->tal_len; i++) + { + json_t *choice; + + GNUNET_assert (! TALER_amount_is_zero (&prices->tal[i])); + choice = GNUNET_JSON_PACK ( + TALER_JSON_pack_amount ("amount", + &prices->tal[i]), + GNUNET_JSON_pack_string ("description", + summary)); + GNUNET_assert (0 == + json_array_append_new (choices, + choice)); + } + order = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("version", + 1), + GNUNET_JSON_pack_string ("order_id", + order_id), + GNUNET_JSON_pack_string ("summary", + summary), + GNUNET_JSON_pack_array_steal ("choices", + choices)); + return order; +} + + +enum GNUNET_GenericReturnValue +AH_paid_amount (const struct TALER_MERCHANT_GetPrivateOrderResponse *osr, + struct TALER_Amount *amount) +{ + const json_t *contract = osr->details.ok.details.paid.contract_terms; + int ci = osr->details.ok.details.paid.choice_index; + const json_t *amount_obj; + struct GNUNET_JSON_Specification cspec[] = { + TALER_JSON_spec_amount_any ("amount", + amount), + GNUNET_JSON_spec_end () + }; + + amount_obj = (0 > ci) + ? contract /* legacy "v0" order, made before the upgrade */ + : json_array_get (json_object_get (contract, + "choices"), + ci); + if (NULL == amount_obj) + { + GNUNET_break (0); + return GNUNET_SYSERR; + } + return GNUNET_JSON_parse (amount_obj, + cspec, + NULL, NULL); +} + + +const struct TALER_Amount * +AH_primary_price (const struct TALER_AmountList *al) +{ + static struct TALER_Amount zero; + const struct TALER_Amount *a; + + GNUNET_assert (0 < AH_currencies_len); + a = TALER_amount_list_find (al, + AH_currencies[0]); + if (NULL != a) + return a; + /* The option is free. Report zero in the primary currency, which is + exactly what a single-currency provider running for free emitted + before there were price lists. */ + GNUNET_assert (GNUNET_OK == + TALER_amount_set_zero (AH_currencies[0], + &zero)); + return &zero; +} + + +enum GNUNET_GenericReturnValue +AH_check_price (const struct TALER_AmountList *al, + const char *section, + const char *option) +{ + switch (TALER_amount_list_check_uniform (al)) + { + case GNUNET_SYSERR: + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Price list error: option `%s' in section `%s' is `%s'," + " which prices some currencies at zero and others above" + " zero. Either all of them are zero (the option is free)" + " or none is.\n", + option, + section, + TALER_amount_list2s (al)); + return GNUNET_SYSERR; + case GNUNET_NO: + /* Free is a first-class answer, and exempt from the coverage check + below: an operator must be able to say "free" without having to + enumerate every currency to do it. */ + return GNUNET_NO; + case GNUNET_OK: + break; + } + if (! TALER_amount_list_covers (al, + (const char *const *) AH_currencies, + AH_currencies_len)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Price list error: option `%s' in section `%s' is `%s'," + " which does not price in exactly the currencies given in" + " [anastasis] CURRENCIES.\n", + option, + section, + TALER_amount_list2s (al)); + /* Naming the missing currency is what makes this actionable: the + failure mode it guards against is an operator adding a currency + and overlooking one of the many COST options. */ + for (unsigned int i = 0; i<AH_currencies_len; i++) + if (NULL == + TALER_amount_list_find (al, + AH_currencies[i])) + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "No price given for currency `%s' in option `%s' of" + " section `%s'\n", + AH_currencies[i], + option, + section); + for (unsigned int i = 0; i<al->tal_len; i++) + { + bool found = false; + + for (unsigned int j = 0; j<AH_currencies_len; j++) + if (0 == strcasecmp (al->tal[i].currency, + AH_currencies[j])) + found = true; + if (! found) + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Currency `%s' is priced in option `%s' of section" + " `%s' but is not in [anastasis] CURRENCIES\n", + al->tal[i].currency, + option, + section); + } + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Closure for #check_method_cost_cb(). + */ +struct MethodCostContext +{ + /** + * Set to true if some enabled authorization method is misconfigured. + */ + bool failure; +}; + + +/** + * Validate the COST of one enabled authorization method. + * + * The plugins themselves are loaded lazily, on the first ``/config`` + * request, so waiting for the loader to notice a bad COST would mean the + * provider starts and only fails once a client asks --- by which time the + * operator is no longer watching. The configuration file can be checked + * without loading anything, so it is checked here. + * + * @param cls a `struct MethodCostContext *` + * @param section configuration section to inspect + */ +static void +check_method_cost_cb (void *cls, + const char *section) +{ + struct MethodCostContext *mcc = cls; + struct TALER_AmountList costs; + + if (0 != strncasecmp (section, + "authorization-", + strlen ("authorization-"))) + return; + if (GNUNET_YES != + GNUNET_CONFIGURATION_get_value_yesno (AH_cfg, + section, + "ENABLED")) + return; + /* An absent COST is an error, not "free": a typo in the option name + must not silently give the method away. Free is spelled with an + empty value, or with a zero in every currency. */ + if (GNUNET_OK != + TALER_config_get_amount_list (AH_cfg, + section, + "COST", + &costs)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + section, + "COST"); + mcc->failure = true; + return; + } + if (GNUNET_SYSERR == + AH_check_price (&costs, + section, + "COST")) + mcc->failure = true; + TALER_amount_list_free (&costs); +} + + +/** + * Determine the currencies this provider prices its service in. + * + * Normally these are given explicitly in [anastasis] CURRENCIES, primary + * currency first. A configuration written before multi-currency support + * has no such option; for those, the single currency the provider was + * already using is derived from the first priced option, which keeps every + * existing configuration file working unchanged. Any disagreement between + * the options is then caught by the per-option coverage check, which can + * name the offending option. + * + * @param cfg configuration to use + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +parse_currencies (const struct GNUNET_CONFIGURATION_Handle *cfg) +{ + char *str; + + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_string (cfg, + "anastasis", + "CURRENCIES", + &str)) + { + const struct TALER_AmountList *als[] = { + &AH_annual_fees, + &AH_truth_upload_fees, + &AH_insurance, + &AH_question_costs + }; + + /* A priced option names the currency authoritatively; failing that, + an option that is free but written out as e.g. "EUR:0" still names + one, which is how a provider running entirely for free is + configured today. */ + for (unsigned int pass = 0; pass < 2; pass++) + for (unsigned int i = 0; i<sizeof (als) / sizeof (als[0]); i++) + { + if ( (0 == pass) && + (GNUNET_OK != + TALER_amount_list_check_uniform (als[i])) ) + continue; + if (0 == als[i]->tal_len) + continue; + if (1 != als[i]->tal_len) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + "anastasis", + "CURRENCIES"); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "A provider pricing in more than one currency must say so explicitly.\n"); + return GNUNET_SYSERR; + } + GNUNET_array_append (AH_currencies, + AH_currencies_len, + GNUNET_strdup (als[i]->tal[0].currency)); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "[anastasis] CURRENCIES not configured, assuming `%s'\n", + AH_currencies[0]); + return GNUNET_OK; + } + /* Not one option names a currency, so there is nothing to derive + from and the legacy scalar fields of /config would have no + currency to report either. */ + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + "anastasis", + "CURRENCIES"); + return GNUNET_SYSERR; + } + for (const char *tok = strtok (str, ";"); + NULL != tok; + tok = strtok (NULL, ";")) + { + if (GNUNET_OK != + TALER_check_currency (tok)) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "anastasis", + "CURRENCIES", + tok); + GNUNET_free (str); + return GNUNET_SYSERR; + } + for (unsigned int i = 0; i<AH_currencies_len; i++) + if (0 == strcasecmp (tok, + AH_currencies[i])) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "anastasis", + "CURRENCIES", + "currency given more than once"); + GNUNET_free (str); + return GNUNET_SYSERR; + } + GNUNET_array_append (AH_currencies, + AH_currencies_len, + GNUNET_strdup (tok)); + } + GNUNET_free (str); + if (0 == AH_currencies_len) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "anastasis", + "CURRENCIES", + "no currency given"); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +/** + * Parse and validate everything this provider charges for. + * + * @param cfg configuration to use + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +parse_prices (const struct GNUNET_CONFIGURATION_Handle *cfg) +{ + static const struct + { + const char *section; + const char *option; + struct TALER_AmountList *al; + } prices[] = { + { "anastasis", "INSURANCE", &AH_insurance }, + { "authorization-question", "COST", &AH_question_costs }, + { "anastasis", "ANNUAL_FEE", &AH_annual_fees }, + { "anastasis", "TRUTH_UPLOAD_FEE", &AH_truth_upload_fees } + }; + struct MethodCostContext mcc = { + .failure = false + }; + + for (unsigned int i = 0; i<sizeof (prices) / sizeof (prices[0]); i++) + { + if (GNUNET_OK != + TALER_config_get_amount_list (cfg, + prices[i].section, + prices[i].option, + prices[i].al)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + prices[i].section, + prices[i].option); + return GNUNET_SYSERR; + } + } + /* The currency list has to exist before anything can be checked + against it, but deriving it from the prices needs them parsed. */ + if (GNUNET_OK != + parse_currencies (cfg)) + return GNUNET_SYSERR; + for (unsigned int i = 0; i<sizeof (prices) / sizeof (prices[0]); i++) + if (GNUNET_SYSERR == + AH_check_price (prices[i].al, + prices[i].section, + prices[i].option)) + return GNUNET_SYSERR; + GNUNET_CONFIGURATION_iterate_sections (cfg, + &check_method_cost_cb, + &mcc); + if (mcc.failure) + return GNUNET_SYSERR; + return GNUNET_OK; } @@ -622,50 +1035,8 @@ run (void *cls, return; } if (GNUNET_OK != - TALER_config_get_amount (config, - "anastasis", - "INSURANCE", - &AH_insurance)) - { - GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, - "anastasis", - "INSURANCE"); - GNUNET_SCHEDULER_shutdown (); - return; - } - if (GNUNET_OK != - TALER_config_get_amount (config, - "authorization-question", - "COST", - &AH_question_cost)) - { - GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, - "authorization-question", - "COST"); - GNUNET_SCHEDULER_shutdown (); - return; - } - if (GNUNET_OK != - TALER_config_get_amount (config, - "anastasis", - "ANNUAL_FEE", - &AH_annual_fee)) + parse_prices (config)) { - GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, - "anastasis", - "ANNUAL_FEE"); - GNUNET_SCHEDULER_shutdown (); - return; - } - if (GNUNET_OK != - TALER_config_get_amount (config, - "anastasis", - "TRUTH_UPLOAD_FEE", - &AH_truth_upload_fee)) - { - GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, - "anastasis", - "TRUTH_UPLOAD_FEE"); GNUNET_SCHEDULER_shutdown (); return; } diff --git a/src/backend/anastasis-httpd.h b/src/backend/anastasis-httpd.h @@ -25,6 +25,7 @@ #include "anastasis_database_lib.h" #include <microhttpd.h> #include <taler/taler_mhd_lib.h> +#include <taler/merchant/get-private-orders-ORDER_ID.h> #include <gnunet/gnunet_mhd_compat.h> @@ -145,24 +146,124 @@ struct TM_HandlerContext extern unsigned long long AH_upload_limit_mb; /** - * Annual fee for the backup account. + * Currencies this provider prices its service in, primary currency + * first. From [anastasis] CURRENCIES. Every priced option below offers + * exactly these currencies; that is checked at startup. */ -extern struct TALER_Amount AH_annual_fee; +extern char **AH_currencies; /** - * Fee for a truth upload. + * Length of the #AH_currencies array. */ -extern struct TALER_Amount AH_truth_upload_fee; +extern unsigned int AH_currencies_len; /** - * Amount of insurance. + * Annual fee for the backup account, per currency. */ -extern struct TALER_Amount AH_insurance; +extern struct TALER_AmountList AH_annual_fees; /** - * Cost for secure question truth download. + * Fee for a truth upload, per currency. */ -extern struct TALER_Amount AH_question_cost; +extern struct TALER_AmountList AH_truth_upload_fees; + +/** + * Amount of insurance, per currency. + */ +extern struct TALER_AmountList AH_insurance; + +/** + * Cost for secure question truth download, per currency. + */ +extern struct TALER_AmountList AH_question_costs; + + +/** + * Check that @a al is a usable price for a configuration option, and + * fail the process if it is not. + * + * A price must be uniformly free or uniformly priced: offering the same + * thing for money in one currency and for nothing in another does not + * price the service, it prices the user's choice of currency, and every + * wallet would simply pick the free one. A priced option must further + * offer exactly the currencies in #AH_currencies, or a user would find + * out that some step of recovery is unavailable in their currency only + * once the backup exists and the money is spent. + * + * @param al price list to check + * @param section configuration section it came from, for diagnostics + * @param option configuration option it came from, for diagnostics + * @return #GNUNET_OK if @a al is priced in all of #AH_currencies, + * #GNUNET_NO if @a al is free (empty or all zero), + * #GNUNET_SYSERR if @a al is unusable; the caller must shut down + */ +enum GNUNET_GenericReturnValue +AH_check_price (const struct TALER_AmountList *al, + const char *section, + const char *option); + + +/** + * Return the price of @a al in the primary currency, for the scalar + * fields that older clients read. + * + * A free option has no entry for any currency, and reports as zero in + * the primary currency --- which is what a single-currency provider + * running for free already emitted before there were price lists. + * + * @param al price list to look in + * @return price in the primary currency; the pointer is only valid + * until the next call + */ +const struct TALER_Amount * +AH_primary_price (const struct TALER_AmountList *al); + + +/** + * Build the skeleton of a merchant "v1" order that offers every currency + * in @a prices as a payable choice, letting the wallet settle in whichever + * one it holds. + * + * Every entry of @a prices becomes a choice: none is dropped and none can + * be zero, because #AH_check_price() refused to let the provider start on + * a price list that mixes free and non-free currencies. So the order is + * either complete and payable in every currency the provider advertises, + * or the caller decided the option was free and does not create an order + * at all. + * + * The caller adds whatever else the order needs (products, deadlines, …) + * to the returned object. + * + * @param order_id order ID to use + * @param summary human-readable summary of the order + * @param prices what the order costs, one entry per currency; must not + * be empty and must not contain a zero + * @return the order object, caller must `json_decref()` it + */ +json_t * +AH_make_order (const char *order_id, + const char *summary, + const struct TALER_AmountList *prices); + + +/** + * Extract from a paid order status the amount the wallet actually paid, + * in the currency it chose. + * + * A "v1" order has no top-level amount; the choice that was settled is at + * ``choices[choice_index]``. A ``choice_index`` of -1 means the contract + * is a legacy "v0" one with a single top-level amount --- which is what + * orders created before this provider was upgraded look like, and they + * still have to settle. + * + * @param osr order status to inspect, must be #TALER_MERCHANT_OSC_PAID + * @param[out] amount set to the amount that was paid + * @return #GNUNET_OK on success, #GNUNET_SYSERR if the contract terms + * did not contain a usable amount + */ +enum GNUNET_GenericReturnValue +AH_paid_amount (const struct TALER_MERCHANT_GetPrivateOrderResponse *osr, + struct TALER_Amount *amount); /** * Our Taler backend to process payments. diff --git a/src/backend/anastasis-httpd_config.c b/src/backend/anastasis-httpd_config.c @@ -65,7 +65,9 @@ add_methods (void *cls, GNUNET_JSON_pack_string ("type", section), TALER_JSON_pack_amount ("cost", - &p->cost)); + AH_primary_price (&p->costs)), + TALER_JSON_pack_amount_list ("costs", + &p->costs)); GNUNET_assert ( 0 == json_array_append_new (method_arr, @@ -82,8 +84,14 @@ AH_handler_config (struct AH_RequestHandler *rh, if (NULL == response) { json_t *method_arr = json_array (); + json_t *currency_arr = json_array (); GNUNET_assert (NULL != method_arr); + GNUNET_assert (NULL != currency_arr); + for (unsigned int i = 0; i<AH_currencies_len; i++) + GNUNET_assert (0 == + json_array_append_new (currency_arr, + json_string (AH_currencies[i]))); { json_t *method; @@ -91,7 +99,9 @@ AH_handler_config (struct AH_RequestHandler *rh, GNUNET_JSON_pack_string ("type", "question"), TALER_JSON_pack_amount ("cost", - &AH_question_cost)); + AH_primary_price (&AH_question_costs)), + TALER_JSON_pack_amount_list ("costs", + &AH_question_costs)); GNUNET_assert ( 0 == json_array_append_new (method_arr, @@ -106,9 +116,12 @@ AH_handler_config (struct AH_RequestHandler *rh, "anastasis"), /* Deliberately incompatible with 0:x:x: the key derivation was fixed to use the full context string, so every key derived by an older client - differs and backups made with one cannot be recovered by the other. */ + differs and backups made with one cannot be recovered by the other. + 3 adds the plural "…s" fields and "currencies" without removing + anything, so the age stays at 1 and a version 2 client still works + against us, seeing only the primary currency. */ GNUNET_JSON_pack_string ("version", - "2:0:1"), + "3:0:1"), GNUNET_JSON_pack_string ("build_version", PACKAGE_VERSION), GNUNET_JSON_pack_string ("implementation", @@ -119,12 +132,23 @@ AH_handler_config (struct AH_RequestHandler *rh, method_arr), GNUNET_JSON_pack_uint64 ("storage_limit_in_megabytes", AH_upload_limit_mb), + GNUNET_JSON_pack_array_steal ("currencies", + currency_arr), + /* The scalar fields report the primary currency and stay for the + benefit of version 2 clients; the plural ones carry the full + list. */ TALER_JSON_pack_amount ("annual_fee", - &AH_annual_fee), + AH_primary_price (&AH_annual_fees)), + TALER_JSON_pack_amount_list ("annual_fees", + &AH_annual_fees), TALER_JSON_pack_amount ("truth_upload_fee", - &AH_truth_upload_fee), + AH_primary_price (&AH_truth_upload_fees)), + TALER_JSON_pack_amount_list ("truth_upload_fees", + &AH_truth_upload_fees), TALER_JSON_pack_amount ("liability_limit", - &AH_insurance), + AH_primary_price (&AH_insurance)), + TALER_JSON_pack_amount_list ("liability_limits", + &AH_insurance), GNUNET_JSON_pack_data_auto ("provider_salt", &AH_provider_salt)); } diff --git a/src/backend/anastasis-httpd_policy-upload.c b/src/backend/anastasis-httpd_policy-upload.c @@ -366,11 +366,14 @@ proposal_cb (struct PolicyUploadContext *puc, "Storing payment request for order `%s'\n", por->details.ok.order_id); + /* The row records the asking price in the primary currency: which of + the offered currencies the wallet will settle in is not known until + it actually pays. */ qs = ANASTASIS_DB_do_insert_recdoc_payment ( &puc->account, (uint32_t) AH_post_counter, &puc->payment_identifier, - &AH_annual_fee); + AH_primary_price (&AH_annual_fees)); if (0 >= qs) { GNUNET_break (0); @@ -441,19 +444,12 @@ check_payment_cb (struct PolicyUploadContext *puc, enum GNUNET_DB_QueryStatus qs; unsigned int years; struct GNUNET_TIME_Relative paid_until; - const json_t *contract; struct TALER_Amount amount; - struct GNUNET_JSON_Specification cspec[] = { - TALER_JSON_spec_amount_any ("amount", - &amount), - GNUNET_JSON_spec_end () - }; + const struct TALER_Amount *fee; - contract = osr->details.ok.details.paid.contract_terms; if (GNUNET_OK != - GNUNET_JSON_parse (contract, - cspec, - NULL, NULL)) + AH_paid_amount (osr, + &amount)) { GNUNET_break (0); puc->resp = TALER_MHD_make_error ( @@ -462,8 +458,24 @@ check_payment_cb (struct PolicyUploadContext *puc, puc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; return; /* continue as planned */ } + /* Divide by the fee in the currency actually paid, not by "the" + fee: with several on offer the two need not be the same one. + Reachable in production if an operator drops a currency while an + order priced in it is still unpaid, hence an error and not an + assertion. */ + fee = TALER_amount_list_find (&AH_annual_fees, + amount.currency); + if (NULL == fee) + { + GNUNET_break (0); + puc->resp = TALER_MHD_make_error ( + TALER_EC_ANASTASIS_GENERIC_BACKEND_ERROR, + "order was paid in a currency this provider does not offer"); + puc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; /* continue as planned */ + } years = TALER_amount_divide2 (&amount, - &AH_annual_fee); + fee); paid_until = GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_YEARS, years); /* add 1 week grace period, otherwise if a user @@ -580,14 +592,16 @@ begin_payment (struct PolicyUploadContext *puc) AH_backend_url); { char *order_id; - struct TALER_Amount upload_fee; + struct TALER_AmountList upload_fees; - if (0 > - TALER_amount_multiply (&upload_fee, - &AH_annual_fee, - puc->years_to_pay)) + TALER_amount_list_copy (&upload_fees, + &AH_annual_fees); + if (GNUNET_OK != + TALER_amount_list_multiply (&upload_fees, + puc->years_to_pay)) { GNUNET_break_op (0); + TALER_amount_list_free (&upload_fees); return TALER_MHD_reply_with_error (puc->con, MHD_HTTP_BAD_REQUEST, TALER_EC_GENERIC_PARAMETER_MALFORMED, @@ -600,24 +614,20 @@ begin_payment (struct PolicyUploadContext *puc) GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Creating order for %u years with payment of %s\n", puc->years_to_pay, - TALER_amount2s (&upload_fee)); - order = json_pack ("{s:o, s:s, s:[{s:s,s:I,s:s}], s:s }", - "amount", TALER_JSON_from_amount (&upload_fee), - "summary", "Anastasis policy storage fee", - "products", - "description", "policy storage fee", - "quantity", (json_int_t) puc->years_to_pay, - "unit", "years", - "order_id", order_id); + TALER_amount_list2s (&upload_fees)); + order = AH_make_order (order_id, + "Anastasis policy storage fee", + &upload_fees); + TALER_amount_list_free (&upload_fees); GNUNET_free (order_id); - if (NULL == order) - { - GNUNET_break (0); - return TALER_MHD_reply_with_error (puc->con, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_JSON_ALLOCATION_FAILURE, - "could not create order"); - } + GNUNET_assert (0 == + json_object_set_new ( + order, + "products", + json_pack ("[{s:s,s:I,s:s}]", + "description", "policy storage fee", + "quantity", (json_int_t) puc->years_to_pay, + "unit", "years"))); } /* Only link into the list once we are actually about to suspend: the list exists to resume suspended connections at shutdown, and cleanup_ctx() @@ -951,7 +961,12 @@ AH_handler_policy_post ( /* generate fresh payment identifier */ GNUNET_CRYPTO_random_block (&puc->payment_identifier, sizeof (struct ANASTASIS_PaymentSecretP)); - if (! TALER_amount_is_zero (&AH_annual_fee)) + /* GNUNET_SYSERR is unreachable: a price list that mixes free and + non-free currencies makes anastasis-httpd refuse to start. */ + GNUNET_assert (GNUNET_SYSERR != + TALER_amount_list_check_uniform (&AH_annual_fees)); + if (GNUNET_NO != + TALER_amount_list_check_uniform (&AH_annual_fees)) { GNUNET_log (GNUNET_ERROR_TYPE_INFO, "No payment identifier, requesting payment\n"); @@ -964,7 +979,7 @@ AH_handler_policy_post ( account_pub, AH_post_counter, &puc->payment_identifier, - &AH_annual_fee); + AH_primary_price (&AH_annual_fees)); if (qs <= 0) return TALER_MHD_reply_with_error (puc->con, MHD_HTTP_INTERNAL_SERVER_ERROR, diff --git a/src/backend/anastasis-httpd_truth-challenge.c b/src/backend/anastasis-httpd_truth-challenge.c @@ -86,9 +86,17 @@ struct ChallengeContext struct ANASTASIS_CRYPTO_TruthKeyP truth_key; /** - * Cost for paying the challenge. + * Cost for paying the challenge, one entry per currency the wallet may + * settle in. Borrowed from the authorization plugin or from + * #AH_question_costs, both of which outlive the request. */ - struct TALER_Amount challenge_cost; + const struct TALER_AmountList *challenge_costs; + + /** + * What the wallet actually paid, in the currency it chose. Only + * meaningful if @e have_payment is true. + */ + struct TALER_Amount challenge_paid; /** * Our handler context. @@ -171,6 +179,12 @@ struct ChallengeContext bool no_payment_identifier_provided; /** + * True if @e challenge_paid holds what the wallet paid for this + * challenge, as read back from the database. + */ + bool have_payment; + + /** * True if this entry is in the #gc_head DLL. */ bool in_list; @@ -443,12 +457,18 @@ begin_refund (const struct ChallengeContext *gc) re->order_id); re->payment_identifier = gc->payment_identifier; re->truth_uuid = gc->truth_uuid; - re->ro = TALER_MERCHANT_post_private_orders_refund_create (AH_ctx, - AH_backend_url, - re->order_id, - &gc->challenge_cost - , - "failed to issue challenge"); + /* Refund what was actually paid, in the currency it was paid in --- + with several currencies on offer the configured price no longer + identifies it. The fallback is for a payment_plugin_managed method, + where the order was never ours to begin with. */ + re->ro = TALER_MERCHANT_post_private_orders_refund_create ( + AH_ctx, + AH_backend_url, + re->order_id, + gc->have_payment + ? &gc->challenge_paid + : AH_primary_price (gc->challenge_costs), + "failed to issue challenge"); { enum TALER_ErrorCode ec; @@ -640,10 +660,13 @@ proposal_cb (struct ChallengeContext *gc, gc->response_code = MHD_HTTP_BAD_GATEWAY; return; } + /* Records the asking price in the primary currency; which currency the + wallet settles in is only known once it has paid, and is written by + ANASTASIS_DB_update_to_challenge_payment_paid() then. */ qs = ANASTASIS_DB_insert_challenge_payment ( &gc->truth_uuid, &gc->payment_identifier, - &gc->challenge_cost); + AH_primary_price (gc->challenge_costs)); if (0 >= qs) { GNUNET_break (0); @@ -730,9 +753,22 @@ check_payment_cb (struct ChallengeContext *gc, { enum GNUNET_DB_QueryStatus qs; + if (GNUNET_OK != + AH_paid_amount (osr, + &gc->challenge_paid)) + { + GNUNET_break (0); + gc->resp = TALER_MHD_make_error ( + TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID, + "no amount given"); + gc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + gc->have_payment = true; qs = ANASTASIS_DB_update_to_challenge_payment_paid ( &gc->truth_uuid, - &gc->payment_identifier); + &gc->payment_identifier, + &gc->challenge_paid); if (0 <= qs) { GNUNET_log (GNUNET_ERROR_TYPE_INFO, @@ -836,17 +872,17 @@ begin_payment (struct ChallengeContext *gc) { json_t *order; - order = GNUNET_JSON_PACK ( - TALER_JSON_pack_amount ("amount", - &gc->challenge_cost), - GNUNET_JSON_pack_string ("summary", - "challenge fee for anastasis service"), - GNUNET_JSON_pack_string ("order_id", - order_id), - GNUNET_JSON_pack_time_rel ("auto_refund", - AUTO_REFUND_TIMEOUT), - GNUNET_JSON_pack_timestamp ("pay_deadline", - pay_deadline)); + order = AH_make_order (order_id, + "challenge fee for anastasis service", + gc->challenge_costs); + GNUNET_assert (0 == + json_object_update_new ( + order, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_time_rel ("auto_refund", + AUTO_REFUND_TIMEOUT), + GNUNET_JSON_pack_timestamp ("pay_deadline", + pay_deadline)))); gc->po = TALER_MERCHANT_post_private_orders_create (AH_ctx, AH_backend_url, order); @@ -1167,13 +1203,18 @@ AH_handler_truth_challenge ( return ret; } - gc->challenge_cost = gc->authorization->cost; + gc->challenge_costs = &gc->authorization->costs; GNUNET_free (method); } if (! gc->authorization->payment_plugin_managed) { - if (! TALER_amount_is_zero (&gc->challenge_cost)) + /* GNUNET_SYSERR is unreachable: anastasis-httpd refuses to start on + a price list that mixes free and non-free currencies. */ + GNUNET_assert (GNUNET_SYSERR != + TALER_amount_list_check_uniform (gc->challenge_costs)); + if (GNUNET_NO != + TALER_amount_list_check_uniform (gc->challenge_costs)) { /* Check database to see if the transaction is paid for */ enum GNUNET_DB_QueryStatus qs; @@ -1190,7 +1231,8 @@ AH_handler_truth_challenge ( qs = ANASTASIS_DB_get_challenge_payment ( &gc->payment_identifier, &gc->truth_uuid, - &paid); + &paid, + &gc->challenge_paid); switch (qs) { case GNUNET_DB_STATUS_HARD_ERROR: @@ -1220,6 +1262,7 @@ AH_handler_truth_challenge ( } GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Payment confirmed\n"); + gc->have_payment = true; break; } } diff --git a/src/backend/anastasis-httpd_truth-solve.c b/src/backend/anastasis-httpd_truth-solve.c @@ -75,9 +75,17 @@ struct SolveContext struct ANASTASIS_CRYPTO_TruthKeyP truth_key; /** - * Cost for paying the challenge. + * Cost for paying the challenge, one entry per currency the wallet may + * settle in. Borrowed from the authorization plugin or from + * #AH_question_costs, both of which outlive the request. */ - struct TALER_Amount challenge_cost; + const struct TALER_AmountList *challenge_costs; + + /** + * What the wallet actually paid, in the currency it chose. Only + * meaningful if @e have_payment is true. + */ + struct TALER_Amount challenge_paid; /** * Our handler context. @@ -166,6 +174,12 @@ struct SolveContext bool no_payment_identifier_provided; /** + * True if @e challenge_paid holds what the wallet paid for this + * challenge, as read back from the database. + */ + bool have_payment; + + /** * True if this entry is in the #gc_head DLL. */ bool in_list; @@ -475,10 +489,13 @@ proposal_cb (struct SolveContext *gc, gc->response_code = MHD_HTTP_BAD_GATEWAY; return; } + /* Records the asking price in the primary currency; which currency the + wallet settles in is only known once it has paid, and is written by + ANASTASIS_DB_update_to_challenge_payment_paid() then. */ qs = ANASTASIS_DB_insert_challenge_payment ( &gc->truth_uuid, &gc->payment_identifier, - &gc->challenge_cost); + AH_primary_price (gc->challenge_costs)); if (0 >= qs) { GNUNET_break (0); @@ -565,9 +582,22 @@ check_payment_cb (struct SolveContext *gc, { enum GNUNET_DB_QueryStatus qs; + if (GNUNET_OK != + AH_paid_amount (osr, + &gc->challenge_paid)) + { + GNUNET_break (0); + gc->resp = TALER_MHD_make_error ( + TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID, + "no amount given"); + gc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + gc->have_payment = true; qs = ANASTASIS_DB_update_to_challenge_payment_paid ( &gc->truth_uuid, - &gc->payment_identifier); + &gc->payment_identifier, + &gc->challenge_paid); if (0 <= qs) { GNUNET_log (GNUNET_ERROR_TYPE_INFO, @@ -671,17 +701,17 @@ begin_payment (struct SolveContext *gc) { json_t *order; - order = GNUNET_JSON_PACK ( - TALER_JSON_pack_amount ("amount", - &gc->challenge_cost), - GNUNET_JSON_pack_string ("summary", - "challenge fee for anastasis service"), - GNUNET_JSON_pack_string ("order_id", - order_id), - GNUNET_JSON_pack_time_rel ("auto_refund", - AUTO_REFUND_TIMEOUT), - GNUNET_JSON_pack_timestamp ("pay_deadline", - pay_deadline)); + order = AH_make_order (order_id, + "challenge fee for anastasis service", + gc->challenge_costs); + GNUNET_assert (0 == + json_object_update_new ( + order, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_time_rel ("auto_refund", + AUTO_REFUND_TIMEOUT), + GNUNET_JSON_pack_timestamp ("pay_deadline", + pay_deadline)))); gc->po = TALER_MERCHANT_post_private_orders_create (AH_ctx, AH_backend_url, order); @@ -1307,11 +1337,11 @@ AH_handler_truth_solve ( GNUNET_free (method); return ret; } - gc->challenge_cost = gc->authorization->cost; + gc->challenge_costs = &gc->authorization->costs; } else { - gc->challenge_cost = AH_question_cost; + gc->challenge_costs = &AH_question_costs; } GNUNET_free (method); } @@ -1320,7 +1350,12 @@ AH_handler_truth_solve ( if ( (is_question) || (! gc->authorization->payment_plugin_managed) ) { - if (! TALER_amount_is_zero (&gc->challenge_cost)) + /* GNUNET_SYSERR is unreachable: anastasis-httpd refuses to start on + a price list that mixes free and non-free currencies. */ + GNUNET_assert (GNUNET_SYSERR != + TALER_amount_list_check_uniform (gc->challenge_costs)); + if (GNUNET_NO != + TALER_amount_list_check_uniform (gc->challenge_costs)) { /* Check database to see if the transaction is paid for */ enum GNUNET_DB_QueryStatus qs; @@ -1337,7 +1372,8 @@ AH_handler_truth_solve ( qs = ANASTASIS_DB_get_challenge_payment ( &gc->payment_identifier, &gc->truth_uuid, - &paid); + &paid, + &gc->challenge_paid); switch (qs) { case GNUNET_DB_STATUS_HARD_ERROR: @@ -1367,6 +1403,7 @@ AH_handler_truth_solve ( } GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Payment confirmed\n"); + gc->have_payment = true; break; } } diff --git a/src/backend/anastasis-httpd_truth-upload.c b/src/backend/anastasis-httpd_truth-upload.c @@ -96,9 +96,10 @@ struct TruthUploadContext struct GNUNET_TIME_Absolute timeout; /** - * Fee that is to be paid for this upload. + * Fee that is to be paid for this upload, one entry per currency the + * wallet may choose to settle in. */ - struct TALER_Amount upload_fee; + struct TALER_AmountList upload_fees; /** * HTTP response code to use on resume, if resp is set. @@ -168,6 +169,7 @@ cleanup_truth_post (struct TM_HandlerContext *hc) MHD_destroy_response (tuc->resp); if (NULL != tuc->json) json_decref (tuc->json); + TALER_amount_list_free (&tuc->upload_fees); GNUNET_free (tuc); } @@ -323,19 +325,12 @@ check_payment_cb (struct TruthUploadContext *tuc, enum GNUNET_DB_QueryStatus qs; unsigned int years; struct GNUNET_TIME_Relative paid_until; - const json_t *contract; struct TALER_Amount amount; - struct GNUNET_JSON_Specification cspec[] = { - TALER_JSON_spec_amount_any ("amount", - &amount), - GNUNET_JSON_spec_end () - }; + const struct TALER_Amount *fee; - contract = osr->details.ok.details.paid.contract_terms; if (GNUNET_OK != - GNUNET_JSON_parse (contract, - cspec, - NULL, NULL)) + AH_paid_amount (osr, + &amount)) { GNUNET_break (0); tuc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; @@ -344,8 +339,21 @@ check_payment_cb (struct TruthUploadContext *tuc, "contract terms in database are malformed"); break; } + /* Divide by the fee in the currency actually paid: with several + on offer, "the" fee is not well defined. */ + fee = TALER_amount_list_find (&AH_truth_upload_fees, + amount.currency); + if (NULL == fee) + { + GNUNET_break (0); + tuc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; + tuc->resp = TALER_MHD_make_error ( + TALER_EC_ANASTASIS_GENERIC_BACKEND_ERROR, + "order was paid in a currency this provider does not offer"); + break; + } years = TALER_amount_divide2 (&amount, - &AH_truth_upload_fee); + fee); paid_until = GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_YEARS, years); /* add 1 week grace period, otherwise if a user @@ -411,27 +419,19 @@ check_payment_cb (struct TruthUploadContext *tuc, "%u, setting up fresh order %s\n", MHD_HTTP_NOT_FOUND, order_id); - order = json_pack ("{s:o, s:s, s:[{s:s,s:I,s:s}], s:s}", - "amount", - TALER_JSON_from_amount (&tuc->upload_fee), - "summary", - "Anastasis challenge storage fee", - "products", - "description", "challenge storage fee", - "quantity", (json_int_t) tuc->years_to_pay, - "unit", "years", - "order_id", - order_id); + order = AH_make_order (order_id, + "Anastasis challenge storage fee", + &tuc->upload_fees); GNUNET_free (order_id); - if (NULL == order) - { - GNUNET_break (0); - tuc->response_code = MHD_HTTP_INTERNAL_SERVER_ERROR; - tuc->resp = TALER_MHD_make_error ( - TALER_EC_GENERIC_JSON_ALLOCATION_FAILURE, - "could not create order"); - break; - } + GNUNET_assert (0 == + json_object_set_new ( + order, + "products", + json_pack ("[{s:s,s:I,s:s}]", + "description", "challenge storage fee", + "quantity", + (json_int_t) tuc->years_to_pay, + "unit", "years"))); tuc->po = TALER_MERCHANT_post_private_orders_create (AH_ctx, AH_backend_url, order); @@ -652,7 +652,12 @@ AH_handler_truth_post ( if (0 == storage_years) storage_years = 1; - if (! TALER_amount_is_zero (&AH_truth_upload_fee)) + /* GNUNET_SYSERR is unreachable: anastasis-httpd refuses to start on a + price list that mixes free and non-free currencies. */ + GNUNET_assert (GNUNET_SYSERR != + TALER_amount_list_check_uniform (&AH_truth_upload_fees)); + if (GNUNET_NO != + TALER_amount_list_check_uniform (&AH_truth_upload_fees)) { struct GNUNET_TIME_Timestamp desired_until; enum GNUNET_DB_QueryStatus qs; @@ -687,10 +692,12 @@ AH_handler_truth_post ( / GNUNET_TIME_UNIT_YEARS.rel_value_us; if (0 != (rem.rel_value_us % GNUNET_TIME_UNIT_YEARS.rel_value_us)) tuc->years_to_pay++; - if (0 > - TALER_amount_multiply (&tuc->upload_fee, - &AH_truth_upload_fee, - tuc->years_to_pay)) + TALER_amount_list_free (&tuc->upload_fees); + TALER_amount_list_copy (&tuc->upload_fees, + &AH_truth_upload_fees); + if (GNUNET_OK != + TALER_amount_list_multiply (&tuc->upload_fees, + tuc->years_to_pay)) { GNUNET_break_op (0); GNUNET_JSON_parse_free (spec); @@ -699,7 +706,7 @@ AH_handler_truth_post ( TALER_EC_GENERIC_PARAMETER_MALFORMED, "storage_duration_years"); } - if (! TALER_amount_is_zero (&tuc->upload_fee)) + if (0 != tuc->upload_fees.tal_len) { GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, "Truth upload payment required (%d)!\n", diff --git a/src/backend/anastasis.conf b/src/backend/anastasis.conf @@ -38,17 +38,46 @@ DB = postgres # Display name of the business running this anastasis provider. # BUSINESS_NAME = ... -# Annual fee for an account -# ANNUAL_FEE = TESTKUDOS:0.1 +# Currencies this provider prices its service in, separated by ";". The +# first one is the primary currency: it is what the scalar fields of +# /config report, for the benefit of clients that predate multi-currency +# support. Every priced option below must give a price in exactly these +# currencies --- anastasis-httpd refuses to start otherwise, so that a +# user cannot discover during recovery that some step is unavailable in +# the currency they hold. +# +# May be omitted by a provider that prices in a single currency; it is +# then taken from the fees themselves. +# CURRENCIES = EUR;CHF;USD + +# Annual fee for an account, one amount per currency in CURRENCIES. +# ANNUAL_FEE = EUR:0.1;CHF:0.1;USD:0.12 # Number of policy uploads included in one annual fee payment ANNUAL_POLICY_UPLOAD_LIMIT = 64 # Insurance -# INSURANCE = TESTKUDOS:1.0 +# INSURANCE = EUR:1.0;CHF:1.0;USD:1.2 # Fee for one upload. -# TRUTH_UPLOAD_FEE = TESTKUDOS:0.1 +# TRUTH_UPLOAD_FEE = EUR:0.1;CHF:0.1;USD:0.12 + +# Each authorization method is priced the same way, in its own +# [authorization-*] section. A method may also be free, which is what an +# IBAN challenge usually wants: the wire transfer the user has to make in +# order to authenticate is already the cost of using it. Free is written +# as an empty value, or as a zero in every currency: +# +# [authorization-iban] +# ENABLED = YES +# COST = +# +# What is *not* allowed is charging in some currencies and not in others, +# e.g. "COST = EUR:1;CHF:0": that does not price the service, it prices +# the user's choice of currency, and anastasis-httpd refuses to start on +# it. Note that the amount the user must transfer for an IBAN challenge +# is a separate option and stays a single amount, since a bank account +# has one currency. # Upload limit per backup, in megabytes diff --git a/src/cli/meson.build b/src/cli/meson.build @@ -1,5 +1,6 @@ # This file is in the public domain check_SCRIPTS = [ + 'test_anastasis_config_currencies', 'test_anastasis_reducer_initialize_state', 'test_anastasis_reducer_select_continent', 'test_anastasis_reducer_select_country', diff --git a/src/cli/setup.sh b/src/cli/setup.sh @@ -43,9 +43,28 @@ function setup() SETUP_PID=$! # Close FD3 exec 3>&- - sed -u '/READY:/ q' <&4 + # Pass the setup's output through until it announces READY:. Note that + # reaching end-of-pipe is NOT success: that is what happens when + # taler-unified-setup.sh dies during startup, and continuing then makes + # the test fail much later against a system that was never brought up, + # hiding the actual error. So insist on having seen the marker. + SETUP_READY=0 + while IFS= read -r line <&4 + do + printf '%s\n' "$line" + case "$line" in + *READY:*) + SETUP_READY=1 + break + ;; + esac + done # Close FD4 exec 4>&- + if [ "1" != "$SETUP_READY" ] + then + exit_fail "taler-unified-setup.sh failed to start the test system" + fi echo "Test system ready" >&2 } diff --git a/src/cli/test_anastasis_config_currencies.sh b/src/cli/test_anastasis_config_currencies.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# This file is part of Anastasis +# Copyright (C) 2026 Anastasis SARL +# +# Anastasis 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. +# +# Anastasis 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 +# Anastasis; see the file COPYING.GPL. If not, see <http://www.gnu.org/licenses/> +# +# Check the multi-currency configuration rules that anastasis-httpd enforces +# at startup: a price must be uniformly free or uniformly priced, and a +# priced option must cover exactly [anastasis] CURRENCIES. +# +# Needs neither a database nor any Taler service. Every case below is +# decided while the configuration is parsed, which happens before +# anastasis-httpd connects to anything. +set -eu + +# Exit code for skipped tests, as expected by meson. +SKIP=77 + +if ! anastasis-httpd --help > /dev/null 2> /dev/null +then + echo "anastasis-httpd not found, skipping" + exit $SKIP +fi + +WORK_DIR=$(mktemp -p "${TMPDIR:-/tmp}" -d test-anastasis-currencies-XXXXXX) +# shellcheck disable=SC2317 +cleanup () { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +BASE="$(pwd)/test_reducer.conf" +LOG="$WORK_DIR/httpd.log" + +# Start anastasis-httpd on test_reducer.conf plus the given overrides and +# report whether the price lists were accepted. Prints "accepted" or +# "refused"; the log is left in $LOG. +# +# The verdict is read off the log rather than off the exit status on +# purpose: this configuration is deliberately incomplete in other ways +# (no BUSINESS_NAME, no initialised database), so the process stops +# either way and only *why* it stopped distinguishes the cases. +try_config () +{ + cat > "$WORK_DIR/test.conf" <<EOF +@INLINE@ $BASE +$1 +EOF + anastasis-httpd -c "$WORK_DIR/test.conf" > "$LOG" 2>&1 & + local pid=$! + sleep 1 + kill "$pid" 2> /dev/null || true + wait "$pid" 2> /dev/null || true + if grep -q "Price list error" "$LOG" + then + echo "refused" + else + echo "accepted" + fi +} + +fail () +{ + cat "$LOG" + echo "FAIL: $1" + exit 1 +} + +# A configuration that prices everything in both currencies; each case +# below deviates from it in exactly one option. +GOOD="[anastasis] +CURRENCIES = TESTKUDOS;EUR +ANNUAL_FEE = TESTKUDOS:4.99;EUR:4 +TRUTH_UPLOAD_FEE = TESTKUDOS:0.01;EUR:0.01 +INSURANCE = TESTKUDOS:1.0;EUR:1.0 + +[authorization-question] +COST = TESTKUDOS:0.0;EUR:0.0" + +echo -n "Test that a well-formed multi-currency configuration is accepted ..." +RES=$(try_config "$GOOD") +if [ "$RES" != "accepted" ] +then + fail "anastasis-httpd rejected a well-formed multi-currency configuration" +fi +echo " OK" + +echo -n "Test that a mixed free/priced option is refused ..." +RES=$(try_config "${GOOD} + +[anastasis] +ANNUAL_FEE = TESTKUDOS:4.99;EUR:0") +if [ "$RES" != "refused" ] +then + fail "anastasis-httpd accepted ANNUAL_FEE = TESTKUDOS:4.99;EUR:0" +fi +# The diagnostic has to name the option, or an operator cannot act on it. +if ! grep -q "ANNUAL_FEE" "$LOG" +then + fail "refusal did not name the offending option" +fi +echo " OK" + +echo -n "Test that an option missing a currency is refused ..." +RES=$(try_config "${GOOD} + +[anastasis] +TRUTH_UPLOAD_FEE = TESTKUDOS:0.01") +if [ "$RES" != "refused" ] +then + fail "anastasis-httpd accepted a TRUTH_UPLOAD_FEE missing EUR" +fi +if ! grep -q "TRUTH_UPLOAD_FEE" "$LOG" || ! grep -q "EUR" "$LOG" +then + fail "refusal did not name the option and the missing currency" +fi +echo " OK" + +echo -n "Test that an option priced in an undeclared currency is refused ..." +RES=$(try_config "${GOOD} + +[anastasis] +TRUTH_UPLOAD_FEE = TESTKUDOS:0.01;EUR:0.01;CHF:0.01") +if [ "$RES" != "refused" ] +then + fail "anastasis-httpd accepted a fee in a currency not in CURRENCIES" +fi +if ! grep -q "CHF" "$LOG" +then + fail "refusal did not name the undeclared currency" +fi +echo " OK" + +echo -n "Test that an option free in every currency is accepted ..." +# Zero is a first-class answer: it is how an operator says a method costs +# nothing, e.g. because the IBAN transfer is already the cost of using it. +RES=$(try_config "${GOOD} + +[authorization-question] +COST = TESTKUDOS:0;EUR:0") +if [ "$RES" != "accepted" ] +then + fail "anastasis-httpd rejected an all-zero COST" +fi +echo " OK" + +echo -n "Test that an empty option means free ..." +RES=$(try_config "${GOOD} + +[authorization-question] +COST =") +if [ "$RES" != "accepted" ] +then + fail "anastasis-httpd rejected an empty COST" +fi +echo " OK" + +echo -n "Test that a single-currency configuration still works ..." +# No CURRENCIES option at all: this is every configuration written before +# multi-currency support, and none of them may break. +RES=$(try_config "") +if [ "$RES" != "accepted" ] +then + fail "anastasis-httpd rejected a single-currency configuration" +fi +echo " OK" + +exit 0 diff --git a/src/cli/test_free_reducer.conf b/src/cli/test_free_reducer.conf @@ -3,6 +3,9 @@ [anastasis] CURRENCY = EUR +# Currencies the service is priced in; every priced option +# below must give a price in exactly these. +CURRENCIES = EUR DB = postgres ANNUAL_FEE = EUR:0 TRUTH_UPLOAD_FEE = EUR:0 diff --git a/src/cli/test_reducer.conf b/src/cli/test_reducer.conf @@ -8,6 +8,7 @@ TALER_RUNTIME_DIR = ${TMPDIR:-${TMP:-/tmp}}/${USER:-}/taler-system-runtime/ [anastasis] CURRENCY = TESTKUDOS +CURRENCIES = TESTKUDOS DB = postgres ANNUAL_FEE = TESTKUDOS:4.99 TRUTH_UPLOAD_FEE = TESTKUDOS:0.01 diff --git a/src/include/anastasis/anastasis-database/get_challenge_payment.h b/src/include/anastasis/anastasis-database/get_challenge_payment.h @@ -33,12 +33,16 @@ * @param payment_secret payment secret which the user must provide with every upload * @param truth_uuid which truth should we check the payment status of * @param[out] paid bool value to show if payment is paid + * @param[out] amount set to the amount that was paid, in the currency it was + * paid in; only meaningful if @a paid is true, as the row still + * carries the asking price until the payment is confirmed * @return transaction status */ enum GNUNET_DB_QueryStatus ANASTASIS_DB_get_challenge_payment ( const struct ANASTASIS_PaymentSecretP *payment_secret, const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, - bool *paid); + bool *paid, + struct TALER_Amount *amount); #endif diff --git a/src/include/anastasis/anastasis-database/update_auth_iban_in_currency.h b/src/include/anastasis/anastasis-database/update_auth_iban_in_currency.h @@ -0,0 +1,48 @@ +/* + This file is part of Anastasis + Copyright (C) 2026 Anastasis SARL + + Anastasis 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. + + Anastasis 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 + Anastasis; see the file COPYING.GPL. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file include/anastasis/anastasis-database/update_auth_iban_in_currency.h + * @brief Anastasis database: stamp the configured currency onto IBAN + * transfers migrated from before amounts carried one + * @author Christian Grothoff + */ +#ifndef ANASTASIS_DATABASE_UPDATE_AUTH_IBAN_IN_CURRENCY_H +#define ANASTASIS_DATABASE_UPDATE_AUTH_IBAN_IN_CURRENCY_H + +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_db_lib.h> +#include "anastasis_service.h" +#include "anastasis/anastasis-database/common.h" + +/** + * Fill in the currency of IBAN wire transfers recorded before amounts + * carried one. + * + * The stasis-0004 migration widened the amount columns to include a + * currency, but SQL cannot know which one: it lives in [anastasis] + * CURRENCY. The rows are therefore left with a NULL currency for this + * function to complete. Unlike the three payment tables, these rows + * cannot simply be stamped with a placeholder --- the IBAN plugin + * compares them against the amount it expects, so a wrong currency would + * silently invalidate every challenge still in flight across the upgrade. + * + * @return transaction status; #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if + * there was nothing left to fill in, which is the normal case + */ +enum GNUNET_DB_QueryStatus +ANASTASIS_DB_update_auth_iban_in_currency (void); + +#endif diff --git a/src/include/anastasis/anastasis-database/update_to_challenge_payment_paid.h b/src/include/anastasis/anastasis-database/update_to_challenge_payment_paid.h @@ -31,11 +31,13 @@ * * @param truth_uuid which challenge received a payment * @param payment_identifier proof of payment, must be unique and match pending payment + * @param amount what was actually paid, in the currency it was paid in * @return transaction status */ enum GNUNET_DB_QueryStatus ANASTASIS_DB_update_to_challenge_payment_paid ( const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, - const struct ANASTASIS_PaymentSecretP *payment_identifier); + const struct ANASTASIS_PaymentSecretP *payment_identifier, + const struct TALER_Amount *amount); #endif diff --git a/src/include/anastasis_authorization_plugin.h b/src/include/anastasis_authorization_plugin.h @@ -141,10 +141,13 @@ struct ANASTASIS_AuthorizationPlugin void *cls; /** - * Cost to GET the /truth using this method. Set by the plugin's - * loader, not by the plugin itself. + * Cost to GET the /truth using this method, one entry per currency + * the provider prices in. Empty (or zero throughout) means the method + * is free, which is what an IBAN-style method wants when the wire + * transfer the user has to make already is the cost of using it. Set + * by the plugin's loader, not by the plugin itself. */ - struct TALER_Amount cost; + struct TALER_AmountList costs; /** * True if the payment is managed internally by the diff --git a/src/include/anastasis_database_lib.h b/src/include/anastasis_database_lib.h @@ -49,6 +49,7 @@ #include "anastasis/anastasis-database/do_verify_challenge_code.h" #include "anastasis/anastasis-database/update_to_challenge_code_satisfied.h" #include "anastasis/anastasis-database/update_totp_counter.h" +#include "anastasis/anastasis-database/update_auth_iban_in_currency.h" #include "anastasis/anastasis-database/get_exists_challenge_code_satisfied.h" #include "anastasis/anastasis-database/insert_challenge_code.h" #include "anastasis/anastasis-database/update_to_challenge_code_sent.h" diff --git a/src/include/anastasis_service.h b/src/include/anastasis_service.h @@ -40,9 +40,19 @@ struct ANASTASIS_AuthorizationMethodConfig const char *type; /** - * Fee charged for accessing key share using this method. + * Fee charged for accessing key share using this method, in the + * provider's primary currency. Kept because a provider speaking + * protocol version 2 offers nothing else. */ struct TALER_Amount usage_fee; + + /** + * Fee charged for accessing key share using this method, one entry + * per currency the provider accepts. Synthesized from @e usage_fee + * for a provider that does not offer the plural field yet. Empty + * means the method is free. + */ + struct TALER_AmountList usage_fees; }; @@ -105,22 +115,53 @@ struct ANASTASIS_Config uint32_t storage_limit_in_megabytes; /** - * Annual fee for an account / policy upload. + * Currencies the provider prices its service in, primary + * currency first. For a provider speaking protocol version 2 + * this is the single currency of @e annual_fee. + */ + const char **currencies; + + /** + * Length of the @e currencies array. + */ + unsigned int currencies_len; + + /** + * Annual fee for an account / policy upload, in the primary + * currency. */ struct TALER_Amount annual_fee; /** - * Fee for a truth upload. + * Annual fee for an account / policy upload, per currency. + * Synthesized from @e annual_fee for a provider speaking + * protocol version 2. + */ + struct TALER_AmountList annual_fees; + + /** + * Fee for a truth upload, in the primary currency. */ struct TALER_Amount truth_upload_fee; /** + * Fee for a truth upload, per currency. + */ + struct TALER_AmountList truth_upload_fees; + + /** * Maximum legal liability for data loss covered by the - * provider. + * provider, in the primary currency. */ struct TALER_Amount liability_limit; /** + * Maximum legal liability for data loss covered by the + * provider, per currency. + */ + struct TALER_AmountList liability_limits; + + /** * Provider salt. */ struct ANASTASIS_CRYPTO_ProviderSaltP provider_salt; diff --git a/src/reducer/anastasis_api_backup_redux.c b/src/reducer/anastasis_api_backup_redux.c @@ -69,6 +69,67 @@ struct Costs /** + * Pick the single amount a provider contributes to an advisory total. + * + * A price list is not a sum: a provider offering both EUR and CHF wants + * one or the other, not both, so adding every entry would tell the user + * they owe twice over. Each provider therefore contributes exactly one + * amount --- in the preferred currency if it offers one, and otherwise in + * its own primary currency, which is the honest answer that this part of + * the backup will be settled separately. + * + * @param common state holding the user's preferred currency + * @param al price list to pick from + * @return the amount to add, NULL if @a al is empty + */ +static const struct TALER_Amount * +preferred_price (const struct ANASTASIS_ReduxCommon *common, + const struct TALER_AmountList *al) +{ + if (NULL != common->preferred_currency) + { + const struct TALER_Amount *a; + + a = TALER_amount_list_find (al, + common->preferred_currency); + if (NULL != a) + return a; + } + if (0 == al->tal_len) + return NULL; + return &al->tal[0]; +} + + +/** + * Check if two price lists offer the same prices, ignoring order. + * + * @param a first list + * @param b second list + * @return true if they agree on every currency + */ +static bool +amount_lists_equal (const struct TALER_AmountList *a, + const struct TALER_AmountList *b) +{ + if (a->tal_len != b->tal_len) + return false; + for (unsigned int i = 0; i<a->tal_len; i++) + { + const struct TALER_Amount *o; + + o = TALER_amount_list_find (b, + a->tal[i].currency); + if ( (NULL == o) || + (0 != TALER_amount_cmp (&a->tal[i], + o)) ) + return false; + } + return true; +} + + +/** * Add amount from @a cost to @a my_cost list. * * @param[in,out] my_cost pointer to list to modify @@ -773,12 +834,11 @@ equiv_provider (const struct PolicyBuilder *pb, } c1 = &j1->config; c2 = &j2->config; - if ( (GNUNET_OK != - TALER_amount_cmp_currency (&c1->truth_upload_fee, - &c2->truth_upload_fee)) || - (0 != - TALER_amount_cmp (&c1->truth_upload_fee, - &c2->truth_upload_fee)) ) + /* Compare the whole price lists, not just the primary currency: two + providers that agree on EUR but differ on which other currencies + they take are not interchangeable for a user holding one of them. */ + if (! amount_lists_equal (&c1->truth_upload_fees, + &c2->truth_upload_fees)) return false; if (c1->methods_len != c2->methods_len) @@ -794,11 +854,8 @@ equiv_provider (const struct PolicyBuilder *pb, if ( (0 == strcmp (m1->type, m2->type)) && - (GNUNET_OK == - TALER_amount_cmp_currency (&m1->usage_fee, - &m2->usage_fee)) && - (0 == TALER_amount_cmp (&m1->usage_fee, - &m2->usage_fee)) ) + amount_lists_equal (&m1->usage_fees, + &m2->usage_fees) ) { matched = true; break; @@ -858,12 +915,20 @@ eval_provider_selection (struct PolicyBuilder *pb, (challenge_size_ok (cfg->storage_limit_in_megabytes, am->challenge_size) ) ) { - found = true; + const struct TALER_Amount *uf; + const struct TALER_Amount *tf; - add_cost (&policy_ent[i].usage_fee, - &ms->usage_fee); - add_cost (&policy_ent[i].usage_fee, - &cfg->truth_upload_fee); + found = true; + uf = preferred_price (pb->common, + &ms->usage_fees); + tf = preferred_price (pb->common, + &cfg->truth_upload_fees); + if (NULL != uf) + add_cost (&policy_ent[i].usage_fee, + uf); + if (NULL != tf) + add_cost (&policy_ent[i].usage_fee, + tf); } } if (! found) @@ -2167,19 +2232,78 @@ update_expiration_cost (struct ANASTASIS_ReduxState *rs, years = expiration_to_years (expiration); + /* Publish the currencies in which the *whole* backup can be settled + with one choice of currency, i.e. the intersection over the + providers that will actually be used. An empty intersection is not + an error: it means the user will pay more than one order in more + than one currency, which is exactly what `upload_fees' then shows. */ + { + bool first = true; + + for (unsigned int i = 0; i < rs->common.currencies_len; i++) + GNUNET_free (rs->common.currencies[i]); + GNUNET_array_grow (rs->common.currencies, + rs->common.currencies_len, + 0); + rs->common.have_currencies = true; + for (unsigned int i = 0; i < rs->common.providers_len; i++) + { + const struct ANASTASIS_ReduxProvider *p = &rs->common.providers[i]; + + if ( (ANASTASIS_RPS_OK != p->status) || + (! p->have_config) || + (MHD_HTTP_OK != p->config.http_status) ) + continue; + if (first) + { + for (unsigned int j = 0; j < p->config.currencies_len; j++) + GNUNET_array_append (rs->common.currencies, + rs->common.currencies_len, + GNUNET_strdup (p->config.currencies[j])); + first = false; + continue; + } + for (unsigned int j = 0; j < rs->common.currencies_len;) + { + bool found = false; + + for (unsigned int k = 0; k < p->config.currencies_len; k++) + if (0 == strcasecmp (rs->common.currencies[j], + p->config.currencies[k])) + found = true; + if (found) + { + j++; + continue; + } + GNUNET_free (rs->common.currencies[j]); + rs->common.currencies[j] + = rs->common.currencies[rs->common.currencies_len - 1]; + GNUNET_array_grow (rs->common.currencies, + rs->common.currencies_len, + rs->common.currencies_len - 1); + } + } + } + /* go over all providers and add up cost */ for (unsigned int i = 0; i < rs->common.providers_len; i++) { const struct ANASTASIS_ReduxProvider *p = &rs->common.providers[i]; + const struct TALER_Amount *af; struct TALER_Amount fee; if ( (ANASTASIS_RPS_OK != p->status) || (! p->have_config) || (MHD_HTTP_OK != p->config.http_status) ) continue; /* skip providers that are down or disabled */ + af = preferred_price (&rs->common, + &p->config.annual_fees); + if (NULL == af) + continue; /* provider is free */ if (0 > TALER_amount_multiply (&fee, - &p->config.annual_fee, + af, years)) { GNUNET_break (0); @@ -2247,11 +2371,16 @@ update_expiration_cost (struct ANASTASIS_ReduxState *rs, return GNUNET_SYSERR; } { + const struct TALER_Amount *tf; struct TALER_Amount fee; + tf = preferred_price (&rs->common, + &p->config.truth_upload_fees); + if (NULL == tf) + continue; /* provider stores truths for free */ if (0 > TALER_amount_multiply (&fee, - &p->config.truth_upload_fee, + tf, years)) { GNUNET_break (0); diff --git a/src/reducer/anastasis_api_redux.c b/src/reducer/anastasis_api_redux.c @@ -108,9 +108,15 @@ struct AuthorizationMethodConfig char *type; /** - * Fee charged for accessing key share using this method. + * Fee charged for accessing key share using this method, in the + * provider's primary currency. */ struct TALER_Amount usage_fee; + + /** + * Fee charged for accessing key share using this method, per currency. + */ + struct TALER_AmountList usage_fees; }; @@ -191,22 +197,48 @@ struct ConfigRequest uint32_t storage_limit_in_megabytes; /** - * Annual fee for an account / policy upload. + * Annual fee for an account / policy upload, primary currency. */ struct TALER_Amount annual_fee; /** - * Fee for a truth upload. + * Annual fee for an account / policy upload, per currency. + */ + struct TALER_AmountList annual_fees; + + /** + * Fee for a truth upload, primary currency. */ struct TALER_Amount truth_upload_fee; /** + * Fee for a truth upload, per currency. + */ + struct TALER_AmountList truth_upload_fees; + + /** * Maximum legal liability for data loss covered by the - * provider. + * provider, primary currency. */ struct TALER_Amount liability_limit; /** + * Maximum legal liability for data loss covered by the + * provider, per currency. + */ + struct TALER_AmountList liability_limits; + + /** + * Currencies the provider accepts, primary currency first. + */ + char **currencies; + + /** + * Length of the @e currencies array. + */ + unsigned int currencies_len; + + /** * Provider salt. */ struct ANASTASIS_CRYPTO_ProviderSaltP provider_salt; @@ -289,7 +321,7 @@ ANASTASIS_REDUX_probe_external_reducer (void) static enum ANASTASIS_GenericState get_generic_state (const struct ANASTASIS_ReduxState *rs) { - unsigned int s; + unsigned int s = UINT_MAX; switch (rs->type) { @@ -407,8 +439,19 @@ free_config_request (struct ConfigRequest *cr) GNUNET_free (cr->url); GNUNET_free (cr->business_name); for (unsigned int i = 0; i<cr->methods_length; i++) + { GNUNET_free (cr->methods[i].type); + TALER_amount_list_free (&cr->methods[i].usage_fees); + } GNUNET_free (cr->methods); + TALER_amount_list_free (&cr->annual_fees); + TALER_amount_list_free (&cr->truth_upload_fees); + TALER_amount_list_free (&cr->liability_limits); + for (unsigned int i = 0; i<cr->currencies_len; i++) + GNUNET_free (cr->currencies[i]); + GNUNET_array_grow (cr->currencies, + cr->currencies_len, + 0); GNUNET_free (cr); } @@ -549,10 +592,26 @@ notify_waiting (struct ConfigRequest *cr) { cfg->methods[i].type = GNUNET_strdup (cr->methods[i].type); cfg->methods[i].usage_fee = cr->methods[i].usage_fee; + TALER_amount_list_copy (&cfg->methods[i].usage_fees, + &cr->methods[i].usage_fees); } cfg->annual_fee = cr->annual_fee; + TALER_amount_list_copy (&cfg->annual_fees, + &cr->annual_fees); cfg->truth_upload_fee = cr->truth_upload_fee; + TALER_amount_list_copy (&cfg->truth_upload_fees, + &cr->truth_upload_fees); cfg->liability_limit = cr->liability_limit; + TALER_amount_list_copy (&cfg->liability_limits, + &cr->liability_limits); + cfg->currencies_len = cr->currencies_len; + cfg->currencies = GNUNET_new_array (cfg->currencies_len, + char *); + for (unsigned int i = 0; i<cfg->currencies_len; i++) + cfg->currencies[i] = GNUNET_strdup (cr->currencies[i]); + if ( (NULL == cfg->currency) && + (0 != cfg->currencies_len) ) + cfg->currency = GNUNET_strdup (cfg->currencies[0]); cfg->provider_salt = cr->provider_salt; cfg->business_name = GNUNET_strdup (cr->business_name); cfg->storage_limit_in_megabytes = cr->storage_limit_in_megabytes; @@ -629,7 +688,10 @@ config_cb (void *cls, GNUNET_free (cr->business_name); cr->business_name = GNUNET_strdup (acfg->details.ok.business_name); for (unsigned int i = 0; i<cr->methods_length; i++) + { GNUNET_free (cr->methods[i].type); + TALER_amount_list_free (&cr->methods[i].usage_fees); + } GNUNET_free (cr->methods); cr->methods = GNUNET_new_array (acfg->details.ok.methods_length, struct AuthorizationMethodConfig); @@ -637,6 +699,8 @@ config_cb (void *cls, { cr->methods[i].type = GNUNET_strdup (acfg->details.ok.methods[i].type); cr->methods[i].usage_fee = acfg->details.ok.methods[i].usage_fee; + TALER_amount_list_copy (&cr->methods[i].usage_fees, + &acfg->details.ok.methods[i].usage_fees); } cr->methods_length = acfg->details.ok.methods_length; cr->storage_limit_in_megabytes = @@ -644,6 +708,24 @@ config_cb (void *cls, cr->annual_fee = acfg->details.ok.annual_fee; cr->truth_upload_fee = acfg->details.ok.truth_upload_fee; cr->liability_limit = acfg->details.ok.liability_limit; + TALER_amount_list_free (&cr->annual_fees); + TALER_amount_list_copy (&cr->annual_fees, + &acfg->details.ok.annual_fees); + TALER_amount_list_free (&cr->truth_upload_fees); + TALER_amount_list_copy (&cr->truth_upload_fees, + &acfg->details.ok.truth_upload_fees); + TALER_amount_list_free (&cr->liability_limits); + TALER_amount_list_copy (&cr->liability_limits, + &acfg->details.ok.liability_limits); + for (unsigned int i = 0; i<cr->currencies_len; i++) + GNUNET_free (cr->currencies[i]); + GNUNET_array_grow (cr->currencies, + cr->currencies_len, + 0); + for (unsigned int i = 0; i<acfg->details.ok.currencies_len; i++) + GNUNET_array_append (cr->currencies, + cr->currencies_len, + GNUNET_strdup (acfg->details.ok.currencies[i])); cr->provider_salt = acfg->details.ok.provider_salt; } notify_waiting (cr); @@ -1259,6 +1341,85 @@ select_country (struct ANASTASIS_ReduxState *rs, ANASTASIS_GENERIC_STATE_USER_ATTRIBUTES_COLLECTING); GNUNET_free (rs->common.selected_country); rs->common.selected_country = GNUNET_strdup (country_code); + /* The country's currency is the default the user is offered; an + explicit "select_currency" overrides it. */ + if (NULL == rs->common.preferred_currency) + for (unsigned int i = 0; i < rs->common.countries_len; i++) + if ( (0 == strcmp (rs->common.countries[i].code, + country_code)) && + (NULL != rs->common.countries[i].currency) ) + { + rs->common.preferred_currency + = GNUNET_strdup (rs->common.countries[i].currency); + break; + } + ANASTASIS_REDUX_return_ (rs, + cb, + cb_cls, + TALER_EC_NONE); + return NULL; +} + + +/** + * DispatchHandler/Callback function which is called for a + * "select_currency" action. + * + * Which currency the user would rather pay in is advisory: every order + * this provider creates carries all of its currencies as choices, so the + * wallet may still settle in another one. What it decides is which total + * the reducer quotes and which currency a UI puts in front of the user. + * + * @param state state to operate on + * @param arguments arguments to use for operation on state + * @param cb callback to call during/after operation + * @param cb_cls callback closure + * @return NULL + */ +static struct ANASTASIS_ReduxAction * +select_currency (struct ANASTASIS_ReduxState *rs, + const json_t *arguments, + ANASTASIS_ActionCallback cb, + void *cb_cls) +{ + const char *currency; + + if (NULL == arguments) + { + ANASTASIS_REDUX_fail_ (rs, + cb, + cb_cls, + TALER_EC_ANASTASIS_REDUCER_INPUT_INVALID, + "arguments missing"); + return NULL; + } + currency = json_string_value (json_object_get (arguments, + "currency")); + if (NULL == currency) + { + ANASTASIS_REDUX_fail_ (rs, + cb, + cb_cls, + TALER_EC_ANASTASIS_REDUCER_INPUT_INVALID, + "'currency' missing"); + return NULL; + } + /* Not checked against the providers: at this point their /config + requests are still in flight, and a currency none of them offers is + not an error either --- it just means no single-currency total can + be quoted. */ + if (GNUNET_OK != + TALER_check_currency (currency)) + { + ANASTASIS_REDUX_fail_ (rs, + cb, + cb_cls, + TALER_EC_ANASTASIS_REDUCER_INPUT_INVALID, + "'currency' malformed"); + return NULL; + } + GNUNET_free (rs->common.preferred_currency); + rs->common.preferred_currency = GNUNET_strdup (currency); ANASTASIS_REDUX_return_ (rs, cb, cb_cls, @@ -1984,6 +2145,16 @@ ANASTASIS_redux_action (const json_t *state, }, { ANASTASIS_GENERIC_STATE_COUNTRY_SELECTING, + "select_currency", + &select_currency + }, + { + ANASTASIS_GENERIC_STATE_USER_ATTRIBUTES_COLLECTING, + "select_currency", + &select_currency + }, + { + ANASTASIS_GENERIC_STATE_COUNTRY_SELECTING, "select_continent", &select_continent }, diff --git a/src/reducer/anastasis_api_redux_free.c b/src/reducer/anastasis_api_redux_free.c @@ -42,10 +42,19 @@ ANASTASIS_REDUX_provider_config_clear_ (struct ANASTASIS_ReduxProviderConfig * ) { for (unsigned int j = 0; j < cfg->methods_len; j++) + { GNUNET_free (cfg->methods[j].type); + TALER_amount_list_free (&cfg->methods[j].usage_fees); + } GNUNET_free (cfg->methods); GNUNET_free (cfg->business_name); GNUNET_free (cfg->currency); + TALER_amount_list_free (&cfg->annual_fees); + TALER_amount_list_free (&cfg->truth_upload_fees); + TALER_amount_list_free (&cfg->liability_limits); + for (unsigned int j = 0; j < cfg->currencies_len; j++) + GNUNET_free (cfg->currencies[j]); + GNUNET_free (cfg->currencies); memset (cfg, 0, sizeof (*cfg)); @@ -149,6 +158,7 @@ free_common (struct ANASTASIS_ReduxCommon *common) json_decref (common->identity_attributes); GNUNET_free (common->selected_continent); GNUNET_free (common->selected_country); + GNUNET_free (common->preferred_currency); } diff --git a/src/reducer/anastasis_api_redux_parse.c b/src/reducer/anastasis_api_redux_parse.c @@ -488,14 +488,18 @@ parse_provider (const char *url, "status", "methods", "annual_fee", + "annual_fees", "truth_upload_fee", + "truth_upload_fees", "liability_limit", + "liability_limits", "business_name", "storage_limit_in_megabytes", "provider_salt", "http_status", "error_code", "currency", + "currencies", NULL }; const char *status; @@ -578,6 +582,7 @@ parse_provider (const char *url, const char *business_name; const char *currency = NULL; const json_t *methods; + const json_t *currencies = NULL; bool no_config = false; struct GNUNET_JSON_Specification cspec[] = { GNUNET_JSON_spec_array_const ("methods", @@ -588,6 +593,24 @@ parse_provider (const char *url, &cfg->truth_upload_fee), TALER_JSON_spec_amount_any ("liability_limit", &cfg->liability_limit), + /* Optional so that a state serialized by an older reducer still + parses; synthesized from the scalars below when absent. */ + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("annual_fees", + &cfg->annual_fees), + NULL), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("truth_upload_fees", + &cfg->truth_upload_fees), + NULL), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("liability_limits", + &cfg->liability_limits), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_array_const ("currencies", + &currencies), + NULL), GNUNET_JSON_spec_string ("business_name", &business_name), GNUNET_JSON_spec_uint32 ("storage_limit_in_megabytes", @@ -630,6 +653,40 @@ parse_provider (const char *url, p->have_config = true; cfg->business_name = GNUNET_strdup (business_name); cfg->currency = dup_or_null (currency); + if (0 == cfg->annual_fees.tal_len) + GNUNET_array_append (cfg->annual_fees.tal, + cfg->annual_fees.tal_len, + cfg->annual_fee); + if (0 == cfg->truth_upload_fees.tal_len) + GNUNET_array_append (cfg->truth_upload_fees.tal, + cfg->truth_upload_fees.tal_len, + cfg->truth_upload_fee); + if (0 == cfg->liability_limits.tal_len) + GNUNET_array_append (cfg->liability_limits.tal, + cfg->liability_limits.tal_len, + cfg->liability_limit); + if (NULL != currencies) + { + size_t cidx; + json_t *cv; + + json_array_foreach (currencies, cidx, cv) + { + if (! json_is_string (cv)) + { + *detail = set_detail ("authentication_providers", + "currencies"); + return GNUNET_SYSERR; + } + GNUNET_array_append (cfg->currencies, + cfg->currencies_len, + GNUNET_strdup (json_string_value (cv))); + } + } + if (0 == cfg->currencies_len) + GNUNET_array_append (cfg->currencies, + cfg->currencies_len, + GNUNET_strdup (cfg->annual_fee.currency)); cfg->methods_len = (unsigned int) json_array_size (methods); cfg->methods = GNUNET_new_array (cfg->methods_len, struct ANASTASIS_ReduxMethodSpec); @@ -638,6 +695,7 @@ parse_provider (const char *url, static const char *const mallowed[] = { "type", "usage_fee", + "usage_fees", NULL }; struct ANASTASIS_ReduxMethodSpec *ms = &cfg->methods[index]; @@ -647,6 +705,10 @@ parse_provider (const char *url, &type), TALER_JSON_spec_amount_any ("usage_fee", &ms->usage_fee), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("usage_fees", + &ms->usage_fees), + NULL), GNUNET_JSON_spec_end () }; @@ -666,6 +728,10 @@ parse_provider (const char *url, return GNUNET_SYSERR; } ms->type = GNUNET_strdup (type); + if (0 == ms->usage_fees.tal_len) + GNUNET_array_append (ms->usage_fees.tal, + ms->usage_fees.tal_len, + ms->usage_fee); } } return GNUNET_OK; @@ -736,6 +802,7 @@ parse_common (const json_t *json, const json_t *currencies = NULL; const char *selected_continent = NULL; const char *selected_country = NULL; + const char *preferred_currency = NULL; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_array_const ("continents", @@ -769,6 +836,10 @@ parse_common (const json_t *json, GNUNET_JSON_spec_string ("selected_country", &selected_country), NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("preferred_currency", + &preferred_currency), + NULL), GNUNET_JSON_spec_end () }; @@ -783,6 +854,7 @@ parse_common (const json_t *json, } common->selected_continent = dup_or_null (selected_continent); common->selected_country = dup_or_null (selected_country); + common->preferred_currency = dup_or_null (preferred_currency); common->identity_attributes = json_incref ((json_t *) identity_attributes); if (NULL != currencies) { @@ -2068,6 +2140,7 @@ static const char *const backup_allowed[] = { "currencies", "selected_continent", "selected_country", + "preferred_currency", "authentication_methods", "policies", "policy_providers", @@ -2097,6 +2170,7 @@ static const char *const recovery_allowed[] = { "currencies", "selected_continent", "selected_country", + "preferred_currency", "recovery_information", "recovery_document", "challenge_feedback", diff --git a/src/reducer/anastasis_api_redux_serialize.c b/src/reducer/anastasis_api_redux_serialize.c @@ -269,31 +269,51 @@ serialize_providers (const struct ANASTASIS_ReduxCommon *common) GNUNET_JSON_pack_string ("type", ms->type), TALER_JSON_pack_amount ("usage_fee", - &ms->usage_fee)))); + &ms->usage_fee), + TALER_JSON_pack_amount_list ("usage_fees", + &ms->usage_fees)))); + } + { + json_t *currencies = json_array (); + + GNUNET_assert (NULL != currencies); + for (unsigned int j = 0; j < cfg->currencies_len; j++) + GNUNET_assert (0 == + json_array_append_new ( + currencies, + json_string (cfg->currencies[j]))); + po = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("status", + status), + GNUNET_JSON_pack_array_steal ("methods", + methods), + GNUNET_JSON_pack_array_steal ("currencies", + currencies), + TALER_JSON_pack_amount ("annual_fee", + &cfg->annual_fee), + TALER_JSON_pack_amount_list ("annual_fees", + &cfg->annual_fees), + TALER_JSON_pack_amount ("truth_upload_fee", + &cfg->truth_upload_fee), + TALER_JSON_pack_amount_list ("truth_upload_fees", + &cfg->truth_upload_fees), + TALER_JSON_pack_amount ("liability_limit", + &cfg->liability_limit), + TALER_JSON_pack_amount_list ("liability_limits", + &cfg->liability_limits), + GNUNET_JSON_pack_string ("business_name", + cfg->business_name), + GNUNET_JSON_pack_uint64 ("storage_limit_in_megabytes", + cfg->storage_limit_in_megabytes), + GNUNET_JSON_pack_data_auto ("provider_salt", + &cfg->provider_salt), + GNUNET_JSON_pack_uint64 ("http_status", + cfg->http_status), + GNUNET_JSON_pack_conditional ( + NULL != cfg->currency, + GNUNET_JSON_pack_string ("currency", + cfg->currency))); } - po = GNUNET_JSON_PACK ( - GNUNET_JSON_pack_string ("status", - status), - GNUNET_JSON_pack_array_steal ("methods", - methods), - TALER_JSON_pack_amount ("annual_fee", - &cfg->annual_fee), - TALER_JSON_pack_amount ("truth_upload_fee", - &cfg->truth_upload_fee), - TALER_JSON_pack_amount ("liability_limit", - &cfg->liability_limit), - GNUNET_JSON_pack_string ("business_name", - cfg->business_name), - GNUNET_JSON_pack_uint64 ("storage_limit_in_megabytes", - cfg->storage_limit_in_megabytes), - GNUNET_JSON_pack_data_auto ("provider_salt", - &cfg->provider_salt), - GNUNET_JSON_pack_uint64 ("http_status", - cfg->http_status), - GNUNET_JSON_pack_conditional ( - NULL != cfg->currency, - GNUNET_JSON_pack_string ("currency", - cfg->currency))); } GNUNET_assert (0 == json_object_set_new (obj, @@ -357,6 +377,10 @@ serialize_common (json_t *obj, set_opt (obj, "selected_country", json_string (common->selected_country)); + if (NULL != common->preferred_currency) + set_opt (obj, + "preferred_currency", + json_string (common->preferred_currency)); } diff --git a/src/reducer/anastasis_api_redux_state.h b/src/reducer/anastasis_api_redux_state.h @@ -282,9 +282,17 @@ struct ANASTASIS_ReduxMethodSpec char *type; /** - * Fee charged for using this method during recovery. + * Fee charged for using this method during recovery, in the provider's + * primary currency. */ struct TALER_Amount usage_fee; + + /** + * Fee charged for using this method during recovery, one entry per + * currency the provider accepts. Never empty: for a provider that + * only reports the scalar fee, it holds that one amount. + */ + struct TALER_AmountList usage_fees; }; @@ -304,21 +312,50 @@ struct ANASTASIS_ReduxProviderConfig unsigned int methods_len; /** - * Fee for storing a policy for a year. + * Fee for storing a policy for a year, in the primary currency. */ struct TALER_Amount annual_fee; /** - * Fee for uploading one truth. + * Fee for storing a policy for a year, per currency. Never empty. + */ + struct TALER_AmountList annual_fees; + + /** + * Fee for uploading one truth, in the primary currency. */ struct TALER_Amount truth_upload_fee; /** - * Maximum liability the provider accepts for data loss. + * Fee for uploading one truth, per currency. Never empty. + */ + struct TALER_AmountList truth_upload_fees; + + /** + * Maximum liability the provider accepts for data loss, in the primary + * currency. */ struct TALER_Amount liability_limit; /** + * Maximum liability the provider accepts for data loss, per currency. + * Never empty. + */ + struct TALER_AmountList liability_limits; + + /** + * Currencies the provider accepts, primary currency first. Never + * empty; for a provider that predates multi-currency support it holds + * the single currency it quotes everything in. + */ + char **currencies; + + /** + * Length of the @e currencies array. + */ + unsigned int currencies_len; + + /** * Salt of this provider. */ struct ANASTASIS_CRYPTO_ProviderSaltP provider_salt; @@ -330,7 +367,8 @@ struct ANASTASIS_ReduxProviderConfig /** * Currency the provider charges in, NULL if not given. Carried - * through for the benefit of user interfaces. + * through for the benefit of user interfaces; @e currencies is the + * authoritative list and this is its first entry. */ char *currency; @@ -1199,6 +1237,14 @@ struct ANASTASIS_ReduxCommon char *selected_country; /** + * Currency the user would rather pay in, NULL if none was chosen. + * Advisory: the orders carry every currency the provider accepts as a + * choice, and the wallet is free to pick another one. Defaults to the + * selected country's currency. + */ + char *preferred_currency; + + /** * True if the state has a @e continents field at all. An array can * legitimately be present but empty, which is different from being * absent, so presence is tracked separately from the length. @@ -1221,7 +1267,10 @@ struct ANASTASIS_ReduxCommon bool have_providers; /** - * Currencies in play, carried through for user interfaces. + * Currencies every provider used by this backup accepts, i.e. the + * currencies in which the whole backup can be paid for with a single + * choice of currency. May be empty, which is not an error: it means + * the user will settle more than one order in more than one currency. */ char **currencies; diff --git a/src/restclient/anastasis_api_config.c b/src/restclient/anastasis_api_config.c @@ -37,8 +37,13 @@ * string: every key derived by a 0:x:x client differs, so backups written by * one side cannot be recovered by the other. Hence 0 must stay outside of * the compatibility range. + * + * Raised to 3 for multi-currency pricing, which only *added* the plural + * fee fields and "currencies" to /config. The age therefore stays at 1: + * a version 2 provider is still usable, we just synthesize the plural + * fields from the scalar ones below. */ -#define ANASTASIS_PROTOCOL_CURRENT 2 +#define ANASTASIS_PROTOCOL_CURRENT 3 /** * How many versions are we backwards compatible with? @@ -77,6 +82,256 @@ struct ANASTASIS_ConfigOperation /** + * Parse a successful GET /config response and hand it to @a cb. + * + * Kept apart from #handle_config_finished() because the price lists are + * heap allocated: every exit path from here has to release them, and a + * flat sequence of `break's out of a switch cannot express that. + * + * @param json the response to parse + * @param url provider URL, for logging + * @param[in,out] acfg configuration to fill in and report + * @param cb function to call with the parsed configuration + * @param cb_cls closure for @a cb + * @return #GNUNET_OK if @a cb was invoked, #GNUNET_SYSERR if @a json was + * unusable and the caller must report the failure itself + */ +static enum GNUNET_GenericReturnValue +handle_config_ok (const json_t *json, + const char *url, + struct ANASTASIS_Config *acfg, + ANASTASIS_ConfigCallback cb, + void *cb_cls) +{ + const char *name; + const json_t *methods; + const json_t *currencies = NULL; + struct TALER_JSON_ProtocolVersion pv; + struct ANASTASIS_AuthorizationMethodConfig *mcfg = NULL; + const char **carr = NULL; + unsigned int carr_len = 0; + enum GNUNET_GenericReturnValue ret = GNUNET_SYSERR; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("name", + &name), + GNUNET_JSON_spec_string ("business_name", + &acfg->details.ok.business_name), + GNUNET_JSON_spec_string ("version", + &acfg->details.ok.version), + TALER_JSON_spec_version ("version", + &pv), + GNUNET_JSON_spec_array_const ("methods", + &methods), + GNUNET_JSON_spec_uint32 ("storage_limit_in_megabytes", + &acfg->details.ok.storage_limit_in_megabytes), + TALER_JSON_spec_amount_any ("annual_fee", + &acfg->details.ok.annual_fee), + TALER_JSON_spec_amount_any ("truth_upload_fee", + &acfg->details.ok.truth_upload_fee), + TALER_JSON_spec_amount_any ("liability_limit", + &acfg->details.ok.liability_limit), + /* Added in protocol version 3. A version 2 provider omits them, and + they are synthesized from the scalar fields further down. */ + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_array_const ("currencies", + &currencies), + NULL), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("annual_fees", + &acfg->details.ok.annual_fees), + NULL), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("truth_upload_fees", + &acfg->details.ok.truth_upload_fees), + NULL), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("liability_limits", + &acfg->details.ok.liability_limits), + NULL), + GNUNET_JSON_spec_fixed_auto ("provider_salt", + &acfg->details.ok.provider_salt), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json, + spec, + NULL, NULL)) + { + GNUNET_break_op (0); + json_dumpf (json, + stderr, + JSON_INDENT (2)); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + return GNUNET_SYSERR; + } + if (0 != strcmp (name, + "anastasis")) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + if ( (ANASTASIS_PROTOCOL_CURRENT < pv.current) && + (ANASTASIS_PROTOCOL_CURRENT < pv.current - pv.age) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Provider protocol version too new\n"); + acfg->ec = TALER_EC_GENERIC_VERSION_MALFORMED; + goto cleanup; + } + if ( (ANASTASIS_PROTOCOL_CURRENT > pv.current) && + (ANASTASIS_PROTOCOL_CURRENT - ANASTASIS_PROTOCOL_AGE > pv.current) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Provider protocol version too old\n"); + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_VERSION_MALFORMED; + goto cleanup; + } + acfg->details.ok.methods_length = (unsigned int) json_array_size (methods); + if (((size_t) acfg->details.ok.methods_length) != + json_array_size (methods)) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + /* Bound the arrays so a malicious provider cannot force an unbounded + allocation (a ~40 MB response could otherwise yield ~10^6 elements). */ + if (acfg->details.ok.methods_length > 1024) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + + /* Currencies: given outright by a version 3 provider, otherwise the + single currency the provider was already quoting everything in. */ + if (NULL != currencies) + { + size_t clen = json_array_size (currencies); + size_t idx; + json_t *entry; + + if ( (0 == clen) || + (clen > 1024) ) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + carr = GNUNET_new_array (clen, + const char *); + json_array_foreach (currencies, idx, entry) + { + if (! json_is_string (entry)) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + carr[idx] = json_string_value (entry); + carr_len = idx + 1; + } + } + else + { + carr = GNUNET_new_array (1, + const char *); + carr[0] = acfg->details.ok.annual_fee.currency; + carr_len = 1; + } + acfg->details.ok.currencies = carr; + acfg->details.ok.currencies_len = carr_len; + + /* Plural fee fields: a version 2 provider has none, so make a + one-element list out of what it did send. Everything downstream can + then work with lists only. */ + { + struct + { + const struct TALER_Amount *scalar; + struct TALER_AmountList *list; + } fees[] = { + { &acfg->details.ok.annual_fee, &acfg->details.ok.annual_fees }, + { &acfg->details.ok.truth_upload_fee, + &acfg->details.ok.truth_upload_fees }, + { &acfg->details.ok.liability_limit, + &acfg->details.ok.liability_limits } + }; + + for (unsigned int i = 0; i<sizeof (fees) / sizeof (fees[0]); i++) + { + if (0 != fees[i].list->tal_len) + continue; + GNUNET_array_append (fees[i].list->tal, + fees[i].list->tal_len, + *fees[i].scalar); + } + } + + mcfg = GNUNET_new_array (GNUNET_NZL (acfg->details.ok.methods_length), + struct ANASTASIS_AuthorizationMethodConfig); + for (unsigned int i = 0; i<acfg->details.ok.methods_length; i++) + { + struct ANASTASIS_AuthorizationMethodConfig *m = &mcfg[i]; + struct GNUNET_JSON_Specification ispec[] = { + GNUNET_JSON_spec_string ("type", + &m->type), + TALER_JSON_spec_amount_any ("cost", + &m->usage_fee), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_list ("costs", + &m->usage_fees), + NULL), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json_array_get (methods, + i), + ispec, + NULL, NULL)) + { + GNUNET_break_op (0); + acfg->ec = TALER_EC_GENERIC_REPLY_MALFORMED; + goto cleanup; + } + if (0 == m->usage_fees.tal_len) + GNUNET_array_append (m->usage_fees.tal, + m->usage_fees.tal_len, + m->usage_fee); + } + acfg->details.ok.methods = mcfg; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Good backend found at `%s'\n", + url); + cb (cb_cls, + acfg); + ret = GNUNET_OK; +cleanup: + if (NULL != mcfg) + { + for (unsigned int i = 0; i<acfg->details.ok.methods_length; i++) + TALER_amount_list_free (&mcfg[i].usage_fees); + GNUNET_free (mcfg); + } + GNUNET_free (carr); + GNUNET_JSON_parse_free (spec); + if (GNUNET_OK != ret) + { + /* Everything in there points into memory just released, and the + caller is about to report a failure with the same struct. */ + memset (&acfg->details.ok, + 0, + sizeof (acfg->details.ok)); + } + return ret; +} + + +/** * Function called when we're done processing the * HTTP /config request. * @@ -106,129 +361,18 @@ handle_config_finished (void *cls, co->url); break; case MHD_HTTP_OK: + if (GNUNET_OK == + handle_config_ok (json, + co->url, + &acfg, + co->cb, + co->cb_cls)) { - const char *name; - const json_t *methods; - struct TALER_JSON_ProtocolVersion pv; - struct GNUNET_JSON_Specification spec[] = { - GNUNET_JSON_spec_string ("name", - &name), - GNUNET_JSON_spec_string ("business_name", - &acfg.details.ok.business_name), - GNUNET_JSON_spec_string ("version", - &acfg.details.ok.version), - TALER_JSON_spec_version ("version", - &pv), - GNUNET_JSON_spec_array_const ("methods", - &methods), - GNUNET_JSON_spec_uint32 ("storage_limit_in_megabytes", - &acfg.details.ok.storage_limit_in_megabytes), - TALER_JSON_spec_amount_any ("annual_fee", - &acfg.details.ok.annual_fee), - TALER_JSON_spec_amount_any ("truth_upload_fee", - &acfg.details.ok.truth_upload_fee), - TALER_JSON_spec_amount_any ("liability_limit", - &acfg.details.ok.liability_limit), - GNUNET_JSON_spec_fixed_auto ("provider_salt", - &acfg.details.ok.provider_salt), - GNUNET_JSON_spec_end () - }; - - if (GNUNET_OK != - GNUNET_JSON_parse (json, - spec, - NULL, NULL)) - { - GNUNET_break_op (0); - json_dumpf (json, - stderr, - JSON_INDENT (2)); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_REPLY_MALFORMED; - break; - } - if (0 != strcmp (name, - "anastasis")) - { - GNUNET_JSON_parse_free (spec); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_REPLY_MALFORMED; - break; - } - if ( (ANASTASIS_PROTOCOL_CURRENT < pv.current) && - (ANASTASIS_PROTOCOL_CURRENT < pv.current - pv.age) ) - { - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Provider protocol version too new\n"); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_VERSION_MALFORMED; - break; - } - if ( (ANASTASIS_PROTOCOL_CURRENT > pv.current) && - (ANASTASIS_PROTOCOL_CURRENT - ANASTASIS_PROTOCOL_AGE > pv.current) ) - { - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Provider protocol version too old\n"); - GNUNET_break_op (0); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_VERSION_MALFORMED; - break; - } - acfg.details.ok.methods_length = (unsigned int) json_array_size (methods); - if (((size_t) acfg.details.ok.methods_length) != - json_array_size (methods)) - { - GNUNET_break_op (0); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_REPLY_MALFORMED; - break; - } - /* Bound the array so a malicious provider cannot force an unbounded - stack VLA (a ~40 MB response could otherwise yield ~10^6 elements). */ - if (acfg.details.ok.methods_length > 1024) - { - GNUNET_break_op (0); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_REPLY_MALFORMED; - break; - } - { - struct ANASTASIS_AuthorizationMethodConfig mcfg[ - GNUNET_NZL (acfg.details.ok.methods_length)]; - - for (unsigned int i = 0; i<acfg.details.ok.methods_length; i++) - { - struct ANASTASIS_AuthorizationMethodConfig *m = &mcfg[i]; - struct GNUNET_JSON_Specification ispec[] = { - GNUNET_JSON_spec_string ("type", - &m->type), - TALER_JSON_spec_amount_any ("cost", - &m->usage_fee), - GNUNET_JSON_spec_end () - }; - - if ( (GNUNET_OK != - GNUNET_JSON_parse (json_array_get (methods, - i), - ispec, - NULL, NULL)) ) - { - GNUNET_break_op (0); - acfg.http_status = 0; - acfg.ec = TALER_EC_GENERIC_REPLY_MALFORMED; - goto end; - } - } - acfg.details.ok.methods = mcfg; - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Good backend found at `%s'\n", - co->url); - co->cb (co->cb_cls, - &acfg); - ANASTASIS_config_cancel (co); - return; - } + ANASTASIS_config_cancel (co); + return; } + acfg.http_status = 0; + break; case MHD_HTTP_BAD_REQUEST: /* This should never happen, either us or the anastasis server is buggy (or API version conflict); just pass JSON reply to the application */ @@ -248,7 +392,6 @@ handle_config_finished (void *cls, GNUNET_break_op (0); break; } -end: co->cb (co->cb_cls, &acfg); ANASTASIS_config_cancel (co); diff --git a/src/stasis/anastasis-db_pg.h b/src/stasis/anastasis-db_pg.h @@ -65,7 +65,11 @@ struct PostgresClosure const char *transaction_name; /** - * Currency we accept payments in. + * Currency of the provider's own bank account, from [anastasis] CURRENCY. + * This is *not* the currency the service is priced in: since amounts in + * the database carry their own currency and a provider may price in + * several at once, this is only used to interpret IBAN wire transfers, + * which are made into one bank account and are therefore in one currency. */ char *currency; diff --git a/src/stasis/anastasis-dbinit.c b/src/stasis/anastasis-dbinit.c @@ -77,6 +77,26 @@ run (void *cls, ANASTASIS_DB_fini (); return; } + /* Finish what the stasis-0004 migration could not: SQL has no access to + [anastasis] CURRENCY, so the IBAN transfers it widened were left with a + NULL currency. Idempotent, and a no-op for a freshly created database. */ + { + enum GNUNET_DB_QueryStatus qs; + + qs = ANASTASIS_DB_update_auth_iban_in_currency (); + if (0 > qs) + { + fprintf (stderr, + "Failed to set the currency of migrated IBAN transfers.\n"); + global_ret = EXIT_FAILURE; + ANASTASIS_DB_fini (); + return; + } + if (0 < qs) + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Stamped currency onto %d migrated IBAN transfer(s)\n", + (int) qs); + } if (gc_db) { struct GNUNET_TIME_Absolute expire_backups; diff --git a/src/stasis/do_insert_recdoc_payment.c b/src/stasis/do_insert_recdoc_payment.c @@ -38,8 +38,8 @@ ANASTASIS_DB_do_insert_recdoc_payment ( struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (account_pub), GNUNET_PQ_query_param_uint32 (&post_counter), - TALER_PQ_query_param_amount (pg->conn, - amount), + TALER_PQ_query_param_amount_with_currency (pg->conn, + amount), GNUNET_PQ_query_param_auto_from_type (payment_secret), GNUNET_PQ_query_param_timestamp (&now), GNUNET_PQ_query_param_timestamp (&transient_expiration), diff --git a/src/stasis/do_insert_recdoc_payment.sql b/src/stasis/do_insert_recdoc_payment.sql @@ -18,7 +18,7 @@ DROP FUNCTION IF EXISTS anastasis_do_insert_recdoc_payment; CREATE FUNCTION anastasis_do_insert_recdoc_payment ( IN in_user_id BYTEA, IN in_post_counter INT4, - IN in_amount taler_amount, + IN in_amount taler_amount_currency, IN in_payment_identifier BYTEA, IN in_creation_date INT8, IN in_transient_expiration INT8) -- lifetime given to an account created here diff --git a/src/stasis/get_challenge_payment.c b/src/stasis/get_challenge_payment.c @@ -33,13 +33,17 @@ * @param payment_secret payment secret which the user must provide with every upload * @param truth_uuid which truth should we check the payment status of * @param[out] paid bool value to show if payment is paid + * @param[out] amount set to the amount that was paid, in the currency it was + * paid in; only meaningful if @a paid is true, as the row still + * carries the asking price until the payment is confirmed * @return transaction status */ enum GNUNET_DB_QueryStatus ANASTASIS_DB_get_challenge_payment ( const struct ANASTASIS_PaymentSecretP *payment_secret, const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, - bool *paid) + bool *paid, + struct TALER_Amount *amount) { struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (payment_secret), @@ -49,12 +53,18 @@ ANASTASIS_DB_get_challenge_payment ( struct GNUNET_PQ_ResultSpec rs[] = { GNUNET_PQ_result_spec_bool ("paid", paid), + TALER_PQ_result_spec_amount_with_currency ("amount", + amount), GNUNET_PQ_result_spec_end }; + /* The amount is read back because a refund has to return what was + actually paid: with several currencies on offer the configured price + no longer identifies it. */ PREPARE ("get_challenge_payment", "SELECT" " paid" + " ,amount" " FROM anastasis_challenge_payment" " WHERE payment_identifier=$1" " AND truth_uuid=$2" diff --git a/src/stasis/get_pending_challenge_payment.c b/src/stasis/get_pending_challenge_payment.c @@ -58,7 +58,6 @@ ANASTASIS_DB_get_pending_challenge_payment ( "SELECT" " creation_date" ",payment_identifier" - ",amount" " FROM anastasis_challenge_payment" " WHERE" " paid=FALSE" diff --git a/src/stasis/get_recdoc_payment.c b/src/stasis/get_recdoc_payment.c @@ -70,7 +70,6 @@ ANASTASIS_DB_get_recdoc_payment ( "SELECT" " creation_date" ",post_counter > 0 AS valid_counter" - ",amount" ",paid" " FROM anastasis_recdoc_payment" " WHERE user_id=$1" diff --git a/src/stasis/insert_auth_iban_in.c b/src/stasis/insert_auth_iban_in.c @@ -49,8 +49,8 @@ ANASTASIS_DB_insert_auth_iban_in ( struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_uint64 (&wire_reference), GNUNET_PQ_query_param_string (wire_subject), - TALER_PQ_query_param_amount (pg->conn, - amount), + TALER_PQ_query_param_amount_with_currency (pg->conn, + amount), GNUNET_PQ_query_param_string (debit_account), GNUNET_PQ_query_param_string (credit_account), GNUNET_PQ_query_param_timestamp (&execution_date), diff --git a/src/stasis/insert_challenge_payment.c b/src/stasis/insert_challenge_payment.c @@ -43,8 +43,8 @@ ANASTASIS_DB_insert_challenge_payment ( struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get (); struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (truth_uuid), - TALER_PQ_query_param_amount (pg->conn, - amount), + TALER_PQ_query_param_amount_with_currency (pg->conn, + amount), GNUNET_PQ_query_param_auto_from_type (payment_secret), GNUNET_PQ_query_param_timestamp (&now), GNUNET_PQ_query_param_end diff --git a/src/stasis/insert_truth_payment.c b/src/stasis/insert_truth_payment.c @@ -44,8 +44,8 @@ ANASTASIS_DB_insert_truth_payment ( duration); struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (uuid), - TALER_PQ_query_param_amount (pg->conn, - amount), + TALER_PQ_query_param_amount_with_currency (pg->conn, + amount), GNUNET_PQ_query_param_timestamp (&exp), GNUNET_PQ_query_param_end }; diff --git a/src/stasis/iterate_auth_iban_transfers.c b/src/stasis/iterate_auth_iban_transfers.c @@ -67,9 +67,8 @@ test_auth_cb (void *cls, struct TALER_Amount credit; char *wire_subject; struct GNUNET_PQ_ResultSpec rs[] = { - TALER_PQ_result_spec_amount ("credit", - pg->currency, - &credit), + TALER_PQ_result_spec_amount_with_currency ("credit", + &credit), GNUNET_PQ_result_spec_string ("wire_subject", &wire_subject), GNUNET_PQ_result_spec_end diff --git a/src/stasis/meson.build b/src/stasis/meson.build @@ -8,6 +8,7 @@ install_data( 'stasis-0001.sql', 'stasis-0002.sql', 'stasis-0003.sql', + 'stasis-0004.sql', 'drop.sql', install_dir: sqldir, ) @@ -55,6 +56,7 @@ libanastasisdb_SOURCES = [ 'get_pending_challenge_payment.c', 'update_to_challenge_code_satisfied.c', 'update_totp_counter.c', + 'update_auth_iban_in_currency.c', 'update_to_challenge_code_sent.c', 'insert_auth_iban_in.c', 'insert_challenge_payment.c', diff --git a/src/stasis/stasis-0004.sql b/src/stasis/stasis-0004.sql @@ -0,0 +1,89 @@ +-- +-- This file is part of Anastasis +-- Copyright (C) 2026 Anastasis SARL +-- +-- ANASTASIS is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- ANASTASIS is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- ANASTASIS; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +-- Everything in one big transaction +BEGIN; + +-- Check patch versioning is in place. +SELECT _v.register_patch('stasis-0004', NULL, NULL); + +SET search_path TO anastasis; + +-- A provider may now price its service in several currencies at once, so an +-- amount is only meaningful together with the currency it is in. The old +-- taler_amount type has no room for one: it was written on the assumption +-- that the whole provider used exactly one currency, which was then read out +-- of [anastasis] CURRENCY at result-parsing time. Replace it with the +-- merchant's taler_amount_currency, which carries the currency in the row. +-- +-- anastasis_do_insert_recdoc_payment() names taler_amount in its signature +-- and therefore depends on the type. It has to be dropped before the type +-- can be, and dropping it here costs nothing: every anastasis-dbinit run +-- recreates all stored procedures from procedures.sql. Doing it explicitly +-- rather than with DROP TYPE ... CASCADE keeps the dependency visible, and +-- makes an unexpected second dependent an error instead of a silent drop. +DROP FUNCTION IF EXISTS anastasis_do_insert_recdoc_payment; + +CREATE TYPE taler_amount_currency + AS + (val INT8 + ,frac INT4 + ,curr VARCHAR(12) + ); +COMMENT ON TYPE taler_amount_currency + IS 'Stores an amount with its currency, fraction is in units of 1/100000000 of the base value'; + +-- Existing payment rows were all made in the provider's single configured +-- currency, but that currency is in the configuration file and not reachable +-- from here. Stamp them KUDOS: these three columns have never been read back +-- by any released version (the getters either did not select the column or +-- selected it without binding a result), so no decision depends on the value +-- being right, and the alternative -- a NULL currency -- would make them +-- unparseable rather than merely historical. +ALTER TABLE anastasis_truth_payment + ALTER COLUMN amount TYPE taler_amount_currency + USING ROW((amount).val, (amount).frac, 'KUDOS')::taler_amount_currency; +COMMENT ON COLUMN anastasis_truth_payment.amount + IS 'Amount we were paid, in the currency the user chose to pay in. Rows migrated from before multi-currency support read KUDOS regardless of what was actually paid.'; + +ALTER TABLE anastasis_recdoc_payment + ALTER COLUMN amount TYPE taler_amount_currency + USING ROW((amount).val, (amount).frac, 'KUDOS')::taler_amount_currency; +COMMENT ON COLUMN anastasis_recdoc_payment.amount + IS 'Amount we were paid, in the currency the user chose to pay in. Rows migrated from before multi-currency support read KUDOS regardless of what was actually paid.'; + +ALTER TABLE anastasis_challenge_payment + ALTER COLUMN amount TYPE taler_amount_currency + USING ROW((amount).val, (amount).frac, 'KUDOS')::taler_amount_currency; +COMMENT ON COLUMN anastasis_challenge_payment.amount + IS 'Amount we were paid, in the currency the user chose to pay in. This is what a refund of the challenge returns, so it must be the currency actually received. Rows migrated from before multi-currency support read KUDOS regardless of what was actually paid.'; + +-- The IBAN column is different: it *is* read back, by +-- ANASTASIS_DB_iterate_auth_iban_transfers(), and the IBAN plugin compares +-- what it finds against the expected transfer amount. Stamping it KUDOS +-- would make every pre-upgrade transfer fail that comparison and silently +-- invalidate all in-flight IBAN challenges. Leave the currency NULL here; +-- anastasis-dbinit fills it in from [anastasis] CURRENCY, which is the one +-- place that knows the bank account's currency. +ALTER TABLE anastasis_auth_iban_in + ALTER COLUMN credit TYPE taler_amount_currency + USING ROW((credit).val, (credit).frac, NULL)::taler_amount_currency; +COMMENT ON COLUMN anastasis_auth_iban_in.credit + IS 'Amount we were credited, in the currency of the provider bank account ([anastasis] CURRENCY)'; + +DROP TYPE taler_amount; + +COMMIT; diff --git a/src/stasis/test_anastasis_db.c b/src/stasis/test_anastasis_db.c @@ -227,26 +227,45 @@ run (void *cls) &valid_counter, &pi_creation_date)); } - FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != - ANASTASIS_DB_get_challenge_payment ( - &paymentSecretP, - &truth_uuid, - &paid)); - FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != - ANASTASIS_DB_insert_challenge_payment ( - &truth_uuid, - &paymentSecretP, - &amount)); - FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != - ANASTASIS_DB_update_to_challenge_payment_paid ( - &truth_uuid, - &paymentSecretP)); - FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != - ANASTASIS_DB_get_challenge_payment ( - &paymentSecretP, - &truth_uuid, - &paid)); - FAILIF (! paid); + { + struct TALER_Amount paid_amount; + struct TALER_Amount got_amount; + + FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + ANASTASIS_DB_get_challenge_payment ( + &paymentSecretP, + &truth_uuid, + &paid, + &got_amount)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + ANASTASIS_DB_insert_challenge_payment ( + &truth_uuid, + &paymentSecretP, + &amount)); + /* The wallet settles in a currency of its choosing, which need not be + the one the order was quoted in. What comes back out has to be + that one, or a refund would be issued in the wrong currency. */ + GNUNET_assert (GNUNET_OK == + TALER_string_to_amount ("CHF:23", + &paid_amount)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + ANASTASIS_DB_update_to_challenge_payment_paid ( + &truth_uuid, + &paymentSecretP, + &paid_amount)); + FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + ANASTASIS_DB_get_challenge_payment ( + &paymentSecretP, + &truth_uuid, + &paid, + &got_amount)); + FAILIF (! paid); + FAILIF (GNUNET_OK != + TALER_amount_cmp_currency (&paid_amount, + &got_amount)); + FAILIF (0 != TALER_amount_cmp (&paid_amount, + &got_amount)); + } FAILIF (ANASTASIS_DB_STORE_STATUS_SUCCESS != ANASTASIS_DB_do_insert_recovery_document ( &accountPubP, diff --git a/src/stasis/update_auth_iban_in_currency.c b/src/stasis/update_auth_iban_in_currency.c @@ -0,0 +1,66 @@ +/* + This file is part of Anastasis + Copyright (C) 2026 Anastasis SARL + + Anastasis 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. + + Anastasis 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 + Anastasis; see the file COPYING.GPL. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file stasis/update_auth_iban_in_currency.c + * @brief Anastasis database: stamp the configured currency onto IBAN + * transfers migrated from before amounts carried one + * @author Christian Grothoff + */ +#include "platform.h" +#include "anastasis-db_pg.h" +#include "anastasis/anastasis-database/update_auth_iban_in_currency.h" +#include "anastasis/anastasis-database/transaction.h" +#include "anastasis/anastasis-database/preflight.h" +#include <taler/taler_pq_lib.h> + + +/** + * Fill in the currency of IBAN wire transfers recorded before amounts + * carried one. + * + * The stasis-0004 migration widened the amount columns to include a + * currency, but SQL cannot know which one: it lives in [anastasis] + * CURRENCY. The rows are therefore left with a NULL currency for this + * function to complete. Unlike the three payment tables, these rows + * cannot simply be stamped with a placeholder --- the IBAN plugin + * compares them against the amount it expects, so a wrong currency would + * silently invalidate every challenge still in flight across the upgrade. + * + * @return transaction status; #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if + * there was nothing left to fill in, which is the normal case + */ +enum GNUNET_DB_QueryStatus +ANASTASIS_DB_update_auth_iban_in_currency (void) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (pg->currency), + GNUNET_PQ_query_param_end + }; + + /* Idempotent: the WHERE clause matches nothing once the backfill has + run, so re-running anastasis-dbinit is free, and a provider that + changes CURRENCY later does not have its history rewritten. */ + PREPARE ("update_auth_iban_in_currency", + "UPDATE anastasis_auth_iban_in" + " SET credit=ROW((credit).val,(credit).frac,$1)::taler_amount_currency" + " WHERE (credit).curr IS NULL;"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "update_auth_iban_in_currency", + params); +} + + +/* end of update_auth_iban_in_currency.c */ diff --git a/src/stasis/update_to_challenge_payment_paid.c b/src/stasis/update_to_challenge_payment_paid.c @@ -31,23 +31,32 @@ * * @param truth_uuid which challenge received a payment * @param payment_identifier proof of payment, must be unique and match pending payment + * @param amount what was actually paid, in the currency it was paid in * @return transaction status */ enum GNUNET_DB_QueryStatus ANASTASIS_DB_update_to_challenge_payment_paid ( const struct ANASTASIS_CRYPTO_TruthUUIDP *truth_uuid, - const struct ANASTASIS_PaymentSecretP *payment_identifier) + const struct ANASTASIS_PaymentSecretP *payment_identifier, + const struct TALER_Amount *amount) { struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_auto_from_type (payment_identifier), GNUNET_PQ_query_param_auto_from_type (truth_uuid), + TALER_PQ_query_param_amount_with_currency (pg->conn, + amount), GNUNET_PQ_query_param_end }; + /* Until now the row held the asking price in the provider's primary + currency, because at order creation time it was not yet known which + of the offered currencies the wallet would settle in. Now it is, and + a later refund must return that one. */ PREPARE ("update_to_challenge_payment_paid", "UPDATE anastasis_challenge_payment " "SET" - " paid=TRUE " + " paid=TRUE" + ",amount=$3 " "WHERE" " payment_identifier=$1" " AND" diff --git a/src/testing/test_anastasis.c b/src/testing/test_anastasis.c @@ -220,14 +220,19 @@ run (void *cls, "secret-share-1", NULL), /* make the payment */ - TALER_TESTING_cmd_merchant_pay_order ("pay-account", - merchant_url, - MHD_HTTP_OK, - "fetch-proposal", - "withdraw-coin-1", - "EUR:5", - "EUR:4.99", /* must match ANNUAL_FEE in config! */ - NULL), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-account", + merchant_url, + MHD_HTTP_OK, + "fetch-proposal", + "withdraw-coin-1", + "EUR:5", + "EUR:4.99", /* must match ANNUAL_FEE in config! */ + NULL, + /* Anastasis creates "v1" orders now; every configured currency + is a choice and the first one is the primary currency. */ + 0, + NULL), ANASTASIS_TESTING_cmd_secret_share ("secret-share-2", anastasis_url, "salt-request-1", @@ -274,14 +279,19 @@ run (void *cls, MHD_HTTP_OK, "challenge-start-3-pay", NULL), - TALER_TESTING_cmd_merchant_pay_order ("pay-file-challenge", - merchant_url, - MHD_HTTP_OK, - "fetch-challenge-pay-proposal", - "withdraw-coin-2", - "EUR:1", - "EUR:1", /* must match COST in config! */ - NULL), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-file-challenge", + merchant_url, + MHD_HTTP_OK, + "fetch-challenge-pay-proposal", + "withdraw-coin-2", + "EUR:1", + "EUR:1", /* must match COST in config! */ + NULL, + /* Anastasis creates "v1" orders now; every configured currency + is a choice and the first one is the primary currency. */ + 0, + NULL), ANASTASIS_TESTING_cmd_challenge_start ("challenge-start-3-paid", "challenge-start-3-pay", /* payment ref */ "recover-secret-1", diff --git a/src/testing/test_anastasis_api.c b/src/testing/test_anastasis_api.c @@ -153,14 +153,19 @@ run (void *cls, "policy-store-1", NULL), /* make the payment */ - TALER_TESTING_cmd_merchant_pay_order ("pay-account", - merchant_url, - MHD_HTTP_OK, - "fetch-proposal", - "withdraw-coin-1", - "EUR:5", - "EUR:4.99", /* must match ANNUAL_FEE in config! */ - NULL), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-account", + merchant_url, + MHD_HTTP_OK, + "fetch-proposal", + "withdraw-coin-1", + "EUR:5", + "EUR:4.99", /* must match ANNUAL_FEE in config! */ + NULL, + /* Anastasis creates "v1" orders now; every configured currency + is a choice and the first one is the primary currency. */ + 0, + NULL), ANASTASIS_TESTING_cmd_policy_store ("policy-store-2", anastasis_url, "policy-store-1", @@ -222,14 +227,19 @@ run (void *cls, "file-challenge-run-1", NULL), /* make the payment */ - TALER_TESTING_cmd_merchant_pay_order ("pay-account-2", - merchant_url, - MHD_HTTP_OK, - "fetch-proposal-2", - "withdraw-coin-2", - "EUR:1.01", - "EUR:1", - NULL), + TALER_TESTING_cmd_merchant_pay_order_choices ( + "pay-account-2", + merchant_url, + MHD_HTTP_OK, + "fetch-proposal-2", + "withdraw-coin-2", + "EUR:1.01", + "EUR:1", + NULL, + /* Anastasis creates "v1" orders now; every configured currency + is a choice and the first one is the primary currency. */ + 0, + NULL), ANASTASIS_TESTING_cmd_truth_challenge ( "file-challenge-run-2",