commit 97439d8f6b5476ef6496a1ebe7a72fb1b642632c
parent 8e1875a48837c06ce1085fb38ecfab1037b1b6b0
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Mon, 3 Aug 2026 22:04:01 +0200
dd98
Diffstat:
82 files changed, 12318 insertions(+), 457 deletions(-)
diff --git a/src/backend/meson.build b/src/backend/meson.build
@@ -77,6 +77,9 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_get-exchanges.c',
'taler-merchant-httpd_get-templates-TEMPLATE_ID.c',
'taler-merchant-httpd_helper.c',
+ 'taler-merchant-httpd_token-keys.c',
+ 'taler-merchant-httpd_fountains.c',
+ 'taler-merchant-httpd_get-fountain-info.c',
'taler-merchant-httpd_mhd.c',
'taler-merchant-httpd_get-terms.c',
'taler-merchant-httpd_mfa.c',
@@ -88,6 +91,7 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_delete-private-tokens-SERIAL.c',
'taler-merchant-httpd_delete-private-products-PRODUCT_ID.c',
'taler-merchant-httpd_delete-private-orders-ORDER_ID.c',
+ 'taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c',
'taler-merchant-httpd_delete-private-otp-devices-DEVICE_ID.c',
'taler-merchant-httpd_delete-private-templates-TEMPLATE_ID.c',
'taler-merchant-httpd_delete-private-tokenfamilies-TOKEN_FAMILY_SLUG.c',
@@ -109,6 +113,8 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_get-private-products-PRODUCT_ID.c',
'taler-merchant-httpd_get-private-orders.c',
'taler-merchant-httpd_get-private-orders-ORDER_ID.c',
+ 'taler-merchant-httpd_get-private-fountains.c',
+ 'taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c',
'taler-merchant-httpd_get-private-otp-devices.c',
'taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.c',
'taler-merchant-httpd_get-private-incoming.c',
@@ -125,6 +131,7 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_patch-private-units-UNIT.c',
'taler-merchant-httpd_patch-management-instances-INSTANCE.c',
'taler-merchant-httpd_patch-private-orders-ORDER_ID-forget.c',
+ 'taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c',
'taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.c',
'taler-merchant-httpd_patch-private-products-PRODUCT_ID.c',
'taler-merchant-httpd_patch-private-templates-TEMPLATE_ID.c',
@@ -141,6 +148,7 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c',
'taler-merchant-httpd_post-private-orders.c',
'taler-merchant-httpd_post-private-products.c',
+ 'taler-merchant-httpd_post-private-fountains.c',
'taler-merchant-httpd_post-private-otp-devices.c',
'taler-merchant-httpd_post-private-products-PRODUCT_ID-lock.c',
'taler-merchant-httpd_post-private-templates.c',
@@ -153,6 +161,7 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_post-challenge-ID-confirm.c',
'taler-merchant-httpd_post-orders-ORDER_ID-abort.c',
'taler-merchant-httpd_post-orders-ORDER_ID-claim.c',
+ 'taler-merchant-httpd_post-fountain-withdraw.c',
'taler-merchant-httpd_post-orders-ORDER_ID-pay.c',
'taler-merchant-httpd_post-orders-ORDER_ID-paid.c',
'taler-merchant-httpd_post-orders-ORDER_ID-refund.c',
diff --git a/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,70 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c
+ * @brief implementing DELETE /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/delete_fountain.h"
+
+
+enum MHD_Result
+TMH_private_delete_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != mi);
+ GNUNET_assert (NULL != hc->infix);
+ qs = TALER_MERCHANTDB_delete_fountain (TMH_db,
+ mi->settings.id,
+ hc->infix);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "delete_fountain");
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN,
+ hc->infix);
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ return TALER_MHD_reply_static (connection,
+ MHD_HTTP_NO_CONTENT,
+ NULL,
+ NULL,
+ 0);
+ }
+ GNUNET_assert (0);
+ return MHD_NO;
+}
+
+
+/* end of taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h
+ * @brief implementing DELETE /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define TALER_MERCHANT_HTTPD_DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a DELETE "/private/fountains/$FOUNTAIN_ID" request.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_delete_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_dispatcher.c b/src/backend/taler-merchant-httpd_dispatcher.c
@@ -35,6 +35,7 @@
#include "taler-merchant-httpd_delete-private-tokens-SERIAL.h"
#include "taler-merchant-httpd_delete-private-products-PRODUCT_ID.h"
#include "taler-merchant-httpd_delete-private-orders-ORDER_ID.h"
+#include "taler-merchant-httpd_delete-private-fountains-FOUNTAIN_ID.h"
#include "taler-merchant-httpd_delete-private-otp-devices-DEVICE_ID.h"
#include "taler-merchant-httpd_delete-private-templates-TEMPLATE_ID.h"
#include "taler-merchant-httpd_delete-private-tokenfamilies-TOKEN_FAMILY_SLUG.h"
@@ -58,6 +59,8 @@
#include "taler-merchant-httpd_get-private-orders.h"
#include "taler-merchant-httpd_get-private-orders-ORDER_ID.h"
#include "taler-merchant-httpd_get-private-otp-devices.h"
+#include "taler-merchant-httpd_get-private-fountains.h"
+#include "taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h"
#include "taler-merchant-httpd_get-private-otp-devices-DEVICE_ID.h"
#include "taler-merchant-httpd_get-private-statistics-amount-SLUG.h"
#include "taler-merchant-httpd_get-private-statistics-counter-SLUG.h"
@@ -74,6 +77,7 @@
#include "taler-merchant-httpd_patch-private-units-UNIT.h"
#include "taler-merchant-httpd_patch-management-instances-INSTANCE.h"
#include "taler-merchant-httpd_patch-private-orders-ORDER_ID-forget.h"
+#include "taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h"
#include "taler-merchant-httpd_patch-private-otp-devices-DEVICE_ID.h"
#include "taler-merchant-httpd_patch-private-products-PRODUCT_ID.h"
#include "taler-merchant-httpd_patch-private-templates-TEMPLATE_ID.h"
@@ -85,6 +89,7 @@
#include "taler-merchant-httpd_post-management-instances.h"
#include "taler-merchant-httpd_post-management-instances-INSTANCE-auth.h"
#include "taler-merchant-httpd_post-private-token.h"
+#include "taler-merchant-httpd_post-private-fountains.h"
#include "taler-merchant-httpd_post-private-otp-devices.h"
#include "taler-merchant-httpd_post-private-orders.h"
#include "taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h"
@@ -103,6 +108,8 @@
#include "taler-merchant-httpd_post-orders-ORDER_ID-abort.h"
#include "taler-merchant-httpd_post-orders-ORDER_ID-claim.h"
#include "taler-merchant-httpd_post-orders-ORDER_ID-paid.h"
+#include "taler-merchant-httpd_get-fountain-info.h"
+#include "taler-merchant-httpd_post-fountain-withdraw.h"
#include "taler-merchant-httpd_post-orders-ORDER_ID-pay.h"
#include "taler-merchant-httpd_post-orders-ORDER_ID-unclaim.h"
#include "taler-merchant-httpd_post-templates-TEMPLATE_ID.h"
@@ -652,6 +659,44 @@ determine_handler_group (const char **urlp,
.have_id_segment = true,
.handler = &TMH_private_get_incoming_ID
},
+ /* POST /fountains: */
+ {
+ .url_prefix = "/fountains",
+ .permission = "fountains-write",
+ .method = MHD_HTTP_METHOD_POST,
+ .handler = &TMH_private_post_fountains
+ },
+ /* GET /fountains: */
+ {
+ .url_prefix = "/fountains",
+ .permission = "fountains-read",
+ .method = MHD_HTTP_METHOD_GET,
+ .handler = &TMH_private_get_fountains
+ },
+ /* GET /fountains/$ID: */
+ {
+ .url_prefix = "/fountains/",
+ .permission = "fountains-read",
+ .method = MHD_HTTP_METHOD_GET,
+ .have_id_segment = true,
+ .handler = &TMH_private_get_fountains_FOUNTAIN_ID
+ },
+ /* PATCH /fountains/$ID: */
+ {
+ .url_prefix = "/fountains/",
+ .permission = "fountains-write",
+ .method = MHD_HTTP_METHOD_PATCH,
+ .have_id_segment = true,
+ .handler = &TMH_private_patch_fountains_FOUNTAIN_ID
+ },
+ /* DELETE /fountains/$ID: */
+ {
+ .url_prefix = "/fountains/",
+ .permission = "fountains-write",
+ .method = MHD_HTTP_METHOD_DELETE,
+ .have_id_segment = true,
+ .handler = &TMH_private_delete_fountains_FOUNTAIN_ID
+ },
/* POST /otp-devices: */
{
.url_prefix = "/otp-devices",
@@ -1123,6 +1168,21 @@ determine_handler_group (const char **urlp,
.default_only = true,
.handler = &MH_handler_config
},
+ /* GET /fountain/info (DD 98): */
+ {
+ .url_prefix = "/fountain/",
+ .url_suffix = "info",
+ .method = MHD_HTTP_METHOD_GET,
+ .handler = &TMH_get_fountain_info
+ },
+ /* POST /fountain/withdraw (DD 98): */
+ {
+ .url_prefix = "/fountain/",
+ .url_suffix = "withdraw",
+ .method = MHD_HTTP_METHOD_POST,
+ .max_upload = 1024 * 1024,
+ .handler = &TMH_post_fountain_withdraw
+ },
{
.url_prefix = "/exchanges",
.method = MHD_HTTP_METHOD_GET,
diff --git a/src/backend/taler-merchant-httpd_fountains.c b/src/backend/taler-merchant-httpd_fountains.c
@@ -0,0 +1,150 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_fountains.c
+ * @brief shared validation of private fountain grants
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_fountains.h"
+#include "taler-merchant-httpd_token-keys.h"
+#include <taler/taler_json_lib.h>
+
+
+/**
+ * Parse one fountain grant from @a jgrant.
+ *
+ * @param connection connection to report errors on
+ * @param jgrant JSON object to parse
+ * @param[out] grant set to the parsed grant
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error was
+ * already reported, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+parse_fountain_grant (struct MHD_Connection *connection,
+ const json_t *jgrant,
+ struct TALER_MERCHANTDB_FountainGrant *grant)
+{
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("token_family_slug",
+ &grant->token_family_slug),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_limit",
+ &grant->tokens_per_period_limit),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_stash",
+ &grant->tokens_per_period_stash),
+ GNUNET_JSON_spec_uint32 ("key_window_size",
+ &grant->key_window_size),
+ GNUNET_JSON_spec_end ()
+ };
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ jgrant,
+ spec);
+ if (GNUNET_OK != res)
+ return res;
+ /* spec_uint64 also accepts negative JSON integers by unsigned conversion.
+ PostgreSQL INT8 cannot represent these or other values above INT64_MAX. */
+ if ( (grant->tokens_per_period_limit > INT64_MAX) ||
+ (grant->tokens_per_period_stash > INT64_MAX) )
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "quotas must be between 0 and INT64_MAX"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ if (grant->tokens_per_period_stash > grant->tokens_per_period_limit)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "tokens_per_period_stash exceeds tokens_per_period_limit"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (grant->key_window_size > TMH_MAX_FOUNTAIN_KEY_WINDOW)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "key_window_size too large"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ return GNUNET_OK;
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_fountain_grants_parse (
+ struct MHD_Connection *connection,
+ const json_t *jgrants,
+ struct TALER_MERCHANTDB_FountainGrant *grants,
+ unsigned int *grants_len)
+{
+ size_t len = json_array_size (jgrants);
+ size_t idx;
+ json_t *jgrant;
+
+ *grants_len = 0;
+ GNUNET_assert (json_is_array (jgrants));
+ if (len > TMH_MAX_FOUNTAIN_GRANTS)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "too many grants"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ json_array_foreach ((json_t *) jgrants, idx, jgrant)
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ res = parse_fountain_grant (connection,
+ jgrant,
+ &grants[idx]);
+ if (GNUNET_OK != res)
+ return res;
+ for (size_t j = 0; j < idx; j++)
+ {
+ if (0 == strcmp (grants[j].token_family_slug,
+ grants[idx].token_family_slug))
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "duplicate token_family_slug in grants"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ }
+ }
+ *grants_len = (unsigned int) len;
+ return GNUNET_OK;
+}
diff --git a/src/backend/taler-merchant-httpd_fountains.h b/src/backend/taler-merchant-httpd_fountains.h
@@ -0,0 +1,57 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_fountains.h
+ * @brief shared validation of private fountain grants
+ */
+#ifndef TALER_MERCHANT_HTTPD_FOUNTAINS_H
+#define TALER_MERCHANT_HTTPD_FOUNTAINS_H
+
+#include "taler-merchant-httpd.h"
+#include "merchant-database/do_insert_fountain.h"
+
+
+/**
+ * Maximum number of grants in a private fountain request.
+ */
+#define TMH_MAX_FOUNTAIN_GRANTS 192
+
+
+/**
+ * Parse and validate the grants for POST or PATCH of a private fountain.
+ * Checks the grant count, quota bounds, key windows and duplicate slugs.
+ * An empty array is valid. Handling an omitted PATCH field is the caller's job.
+ *
+ * @param connection connection to report errors on
+ * @param jgrants JSON array, already checked by the request body parser
+ * @param[out] grants caller-provided storage for #TMH_MAX_FOUNTAIN_GRANTS entries;
+ * slug strings borrow their storage from @a jgrants
+ * @param[out] grants_len number of grants on success, zero on failure
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was queued,
+ * #GNUNET_SYSERR if queueing the error failed
+ */
+enum GNUNET_GenericReturnValue
+TMH_fountain_grants_parse (
+ struct MHD_Connection *connection,
+ const json_t *jgrants,
+ struct TALER_MERCHANTDB_FountainGrant *grants,
+ unsigned int *grants_len);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_get-fountain-info.c b/src/backend/taler-merchant-httpd_get-fountain-info.c
@@ -0,0 +1,449 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-fountain-info.c
+ * @brief implementing GET /fountain/info request handling (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_get-fountain-info.h"
+#include "taler-merchant-httpd_token-keys.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/get_fountain_by_secret.h"
+#include "merchant-database/get_token_family.h"
+#include "merchant-database/iterate_fountain_grants.h"
+
+
+/**
+ * A grant of the fountain the request authenticated as.
+ */
+struct GrantInfo
+{
+ /**
+ * Slug of the granted token family.
+ */
+ char *token_family_slug;
+
+ /**
+ * Maximum withdrawals per issue-key validity period.
+ */
+ uint64_t tokens_per_period_limit;
+
+ /**
+ * Suggested number of tokens to hold per period.
+ */
+ uint64_t tokens_per_period_stash;
+
+ /**
+ * Number of issue-key slots ahead the wallet may withdraw for.
+ */
+ uint32_t key_window_size;
+};
+
+
+/**
+ * Request-specific context of a GET /fountain/info request.
+ */
+struct InfoContext
+{
+ /**
+ * Grants of the fountain the request authenticated as.
+ */
+ struct GrantInfo *grants;
+
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int grants_len;
+
+ /**
+ * How often the wallet should re-poll.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+
+ /**
+ * Serial of the fountain the request authenticated as.
+ */
+ uint64_t fountain_serial;
+
+ /**
+ * Time this request is processed at.
+ */
+ struct GNUNET_TIME_Timestamp now;
+};
+
+
+/**
+ * Release the request-specific context.
+ *
+ * @param cls a `struct InfoContext *`
+ */
+static void
+info_context_cleanup (void *cls)
+{
+ struct InfoContext *ic = cls;
+
+ for (unsigned int i = 0; i < ic->grants_len; i++)
+ GNUNET_free (ic->grants[i].token_family_slug);
+ GNUNET_array_grow (ic->grants,
+ ic->grants_len,
+ 0);
+ GNUNET_free (ic);
+}
+
+
+/**
+ * Add a grant of the fountain to the context in @a cls. Database
+ * calls must not be made from within the iteration, so the grants are
+ * collected first and expanded into the response afterwards.
+ *
+ * @param cls a `struct InfoContext *`
+ * @param token_family_slug slug of the granted token family
+ * @param token_family_serial serial of the granted token family
+ * @param tokens_per_period_limit maximum withdrawals per period
+ * @param tokens_per_period_stash suggested tokens to hold per period
+ * @param key_window_size number of slots ahead withdrawals are allowed
+ */
+static void
+add_grant (void *cls,
+ const char *token_family_slug,
+ uint64_t token_family_serial,
+ uint64_t tokens_per_period_limit,
+ uint64_t tokens_per_period_stash,
+ uint32_t key_window_size)
+{
+ struct InfoContext *ic = cls;
+ struct GrantInfo gi = {
+ .token_family_slug = GNUNET_strdup (token_family_slug),
+ .tokens_per_period_limit = tokens_per_period_limit,
+ .tokens_per_period_stash = tokens_per_period_stash,
+ .key_window_size = key_window_size
+ };
+
+ (void) token_family_serial;
+ GNUNET_array_append (ic->grants,
+ ic->grants_len,
+ gi);
+}
+
+
+/**
+ * Parse the fountain secret from the "Authorization: Bearer ..."
+ * header of @a connection and compute its hash.
+ *
+ * @param connection connection to inspect
+ * @param[out] h_secret set to the hash of the bearer credential
+ * @return #GNUNET_OK on success, #GNUNET_NO if the header is
+ * missing or malformed
+ */
+static enum GNUNET_GenericReturnValue
+parse_fountain_secret (struct MHD_Connection *connection,
+ struct GNUNET_HashCode *h_secret)
+{
+ static const char bearer[] = "Bearer ";
+ const char *auth;
+ char secret[32];
+
+ auth = MHD_lookup_connection_value (connection,
+ MHD_HEADER_KIND,
+ MHD_HTTP_HEADER_AUTHORIZATION);
+ if ( (NULL == auth) ||
+ (0 != strncmp (auth,
+ bearer,
+ strlen (bearer))) )
+ return GNUNET_NO;
+ auth += strlen (bearer);
+ while (' ' == *auth)
+ auth++;
+ if (GNUNET_OK !=
+ GNUNET_STRINGS_string_to_data (auth,
+ strlen (auth),
+ secret,
+ sizeof (secret)))
+ return GNUNET_NO;
+ GNUNET_CRYPTO_hash (secret,
+ sizeof (secret),
+ h_secret);
+ memset (secret,
+ 0,
+ sizeof (secret));
+ return GNUNET_OK;
+}
+
+
+/**
+ * Authenticate the request by the bearer credential in the
+ * "Authorization" header and load the grants of the fountain.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance the fountain belongs to
+ * @param[in,out] ic context to initialize
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+authenticate (struct MHD_Connection *connection,
+ const char *instance_id,
+ struct InfoContext *ic)
+{
+ struct GNUNET_HashCode h_secret;
+ enum GNUNET_DB_QueryStatus qs;
+
+ if (GNUNET_OK !=
+ parse_fountain_secret (connection,
+ &h_secret))
+ {
+ /* Reply as for an unknown credential, so that the endpoint does
+ not become an oracle for the shape of valid secrets. */
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_UNAUTHORIZED,
+ TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED,
+ "fountain secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ qs = TALER_MERCHANTDB_get_fountain_by_secret (TMH_db,
+ instance_id,
+ &h_secret,
+ &ic->fountain_serial,
+ &ic->poll_freq);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_fountain_by_secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_UNAUTHORIZED,
+ TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED,
+ "fountain secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db,
+ instance_id,
+ ic->fountain_serial,
+ &add_grant,
+ ic);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "iterate_fountain_grants"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Collect the issue keys of the current and the next
+ * @a key_window_size validity periods into @a family, minting them if
+ * they do not exist yet. This is the same lazy minting that order
+ * creation performs, so this GET deliberately writes to the database.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance the token family belongs to
+ * @param ic context of the request
+ * @param gi grant to expand
+ * @param tf details of the token family
+ * @param[in,out] family contract token family to add the keys to
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+collect_keys (struct MHD_Connection *connection,
+ const char *instance_id,
+ const struct InfoContext *ic,
+ const struct GrantInfo *gi,
+ const struct TALER_MERCHANTDB_TokenFamilyDetails *tf,
+ struct TALER_MERCHANT_ContractTokenFamily *family)
+{
+ struct TMH_TokenKeyWindow window;
+ enum GNUNET_GenericReturnValue res;
+
+ res = TMH_token_key_window_get (connection,
+ instance_id,
+ tf,
+ ic->now,
+ gi->key_window_size,
+ &window);
+ if (GNUNET_OK != res)
+ return res;
+ for (unsigned int i = 0; i < window.keys_len; i++)
+ {
+ const struct TALER_MERCHANTDB_TokenFamilyKeyDetails *kd = &window.keys[i];
+ struct TALER_MERCHANT_ContractTokenFamilyKey key;
+
+ TALER_token_issue_pub_copy (&key.pub,
+ &kd->pub);
+ key.valid_after = kd->signature_validity_start;
+ key.valid_before = kd->signature_validity_end;
+ GNUNET_array_append (family->keys,
+ family->keys_len,
+ key);
+ }
+ TMH_token_key_window_free (&window);
+ return GNUNET_OK;
+}
+
+
+/**
+ * Expand one grant of the fountain into its JSON representation,
+ * including the token family metadata and its issue keys.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance the fountain belongs to
+ * @param ic context of the request
+ * @param gi grant to expand
+ * @param[in,out] jgrants JSON array to append the grant to
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+expand_grant (struct MHD_Connection *connection,
+ const char *instance_id,
+ const struct InfoContext *ic,
+ const struct GrantInfo *gi,
+ json_t *jgrants)
+{
+ struct TALER_MERCHANTDB_TokenFamilyDetails tf;
+ struct TALER_MERCHANT_ContractTokenFamily family;
+ json_t *jfamily;
+ enum GNUNET_GenericReturnValue res;
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_MERCHANTDB_get_token_family (TMH_db,
+ instance_id,
+ gi->token_family_slug,
+ &tf);
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs)
+ {
+ /* Grants cascade-delete with their token family, so the family
+ must exist; anything else is an internal failure. */
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_token_family"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ TMH_token_family_to_contract (&tf,
+ &family);
+ res = collect_keys (connection,
+ instance_id,
+ ic,
+ gi,
+ &tf,
+ &family);
+ TALER_MERCHANTDB_token_family_details_free (&tf);
+ if (GNUNET_OK != res)
+ {
+ TALER_MERCHANT_contract_token_family_free (&family);
+ return res;
+ }
+ jfamily = TALER_MERCHANT_json_from_token_family (&family);
+ GNUNET_assert (NULL != jfamily);
+ TALER_MERCHANT_contract_token_family_free (&family);
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jgrants,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ gi->token_family_slug),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_limit",
+ gi->tokens_per_period_limit),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_stash",
+ gi->tokens_per_period_stash),
+ GNUNET_JSON_pack_uint64 ("key_window_size",
+ gi->key_window_size),
+ GNUNET_JSON_pack_object_steal ("token_family",
+ jfamily))));
+ return GNUNET_OK;
+}
+
+
+enum MHD_Result
+TMH_get_fountain_info (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ struct InfoContext *ic = hc->ctx;
+ json_t *jgrants;
+ enum GNUNET_GenericReturnValue res;
+
+ GNUNET_assert (NULL != mi);
+ if (NULL == ic)
+ {
+ ic = GNUNET_new (struct InfoContext);
+ ic->now = GNUNET_TIME_timestamp_get ();
+ hc->ctx = ic;
+ hc->cc = &info_context_cleanup;
+ }
+ res = authenticate (connection,
+ mi->settings.id,
+ ic);
+ if (GNUNET_OK != res)
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ jgrants = json_array ();
+ GNUNET_assert (NULL != jgrants);
+ for (unsigned int i = 0; i < ic->grants_len; i++)
+ {
+ res = expand_grant (connection,
+ mi->settings.id,
+ ic,
+ &ic->grants[i],
+ jgrants);
+ if (GNUNET_OK != res)
+ {
+ json_decref (jgrants);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+ return TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_time_rel ("poll_freq",
+ ic->poll_freq),
+ GNUNET_JSON_pack_array_steal ("grants",
+ jgrants));
+}
+
+
+/* end of taler-merchant-httpd_get-fountain-info.c */
diff --git a/src/backend/taler-merchant-httpd_get-fountain-info.h b/src/backend/taler-merchant-httpd_get-fountain-info.h
@@ -0,0 +1,46 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-fountain-info.h
+ * @brief implementing GET /fountain/info request handling (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_GET_FOUNTAIN_INFO_H
+#define TALER_MERCHANT_HTTPD_GET_FOUNTAIN_INFO_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a GET "/fountain/info" request. Public endpoint,
+ * authenticated by the fountain's bearer credential in the
+ * "Authorization: Bearer $FOUNTAIN_SECRET" header.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_get_fountain_info (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,132 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c
+ * @brief implementing GET /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/get_fountain.h"
+#include "merchant-database/iterate_fountain_grants.h"
+
+
+/**
+ * Add a fountain grant to the JSON array in @a cls.
+ *
+ * @param cls a `json_t *` JSON array to build
+ * @param token_family_slug slug of the granted token family
+ * @param token_family_serial serial of the granted token family
+ * @param tokens_per_period_limit maximum withdrawals per period
+ * @param tokens_per_period_stash suggested tokens to hold per period
+ * @param key_window_size number of slots ahead withdrawals are allowed
+ */
+static void
+add_grant (void *cls,
+ const char *token_family_slug,
+ uint64_t token_family_serial,
+ uint64_t tokens_per_period_limit,
+ uint64_t tokens_per_period_stash,
+ uint32_t key_window_size)
+{
+ json_t *ga = cls;
+
+ (void) token_family_serial;
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ ga,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ token_family_slug),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_limit",
+ tokens_per_period_limit),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_stash",
+ tokens_per_period_stash),
+ GNUNET_JSON_pack_uint64 ("key_window_size",
+ key_window_size))));
+}
+
+
+enum MHD_Result
+TMH_private_get_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ struct TALER_MERCHANTDB_FountainDetails fd;
+ json_t *ga;
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != mi);
+ GNUNET_assert (NULL != hc->infix);
+ qs = TALER_MERCHANTDB_get_fountain (TMH_db,
+ mi->settings.id,
+ hc->infix,
+ &fd);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_fountain");
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN,
+ hc->infix);
+ ga = json_array ();
+ GNUNET_assert (NULL != ga);
+ qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db,
+ mi->settings.id,
+ fd.fountain_serial,
+ &add_grant,
+ ga);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ json_decref (ga);
+ GNUNET_free (fd.description);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "iterate_fountain_grants");
+ }
+ {
+ enum MHD_Result mret;
+
+ mret = TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_string ("description",
+ fd.description),
+ GNUNET_JSON_pack_time_rel ("poll_freq",
+ fd.poll_freq),
+ GNUNET_JSON_pack_array_steal ("grants",
+ ga));
+ GNUNET_free (fd.description);
+ return mret;
+ }
+}
+
+
+/* end of taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-private-fountains-FOUNTAIN_ID.h
+ * @brief implementing GET /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a GET "/private/fountains/$FOUNTAIN_ID" request.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_get_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_get-private-fountains.c b/src/backend/taler-merchant-httpd_get-private-fountains.c
@@ -0,0 +1,89 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-private-fountains.c
+ * @brief implementing GET /private/fountains request handling
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_get-private-fountains.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/iterate_fountains.h"
+
+
+/**
+ * Add fountain details to the JSON array in @a cls.
+ *
+ * @param cls a `json_t *` JSON array to build
+ * @param fountain_id public identifier of the fountain
+ * @param description description of the fountain
+ */
+static void
+add_fountain (void *cls,
+ const char *fountain_id,
+ const char *description)
+{
+ json_t *pa = cls;
+
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ pa,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("fountain_id",
+ fountain_id),
+ GNUNET_JSON_pack_string ("description",
+ description))));
+}
+
+
+enum MHD_Result
+TMH_private_get_fountains (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ json_t *pa;
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != mi);
+ pa = json_array ();
+ GNUNET_assert (NULL != pa);
+ qs = TALER_MERCHANTDB_iterate_fountains (TMH_db,
+ mi->settings.id,
+ &add_fountain,
+ pa);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ json_decref (pa);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "iterate_fountains");
+ }
+ return TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_array_steal ("fountains",
+ pa));
+}
+
+
+/* end of taler-merchant-httpd_get-private-fountains.c */
diff --git a/src/backend/taler-merchant-httpd_get-private-fountains.h b/src/backend/taler-merchant-httpd_get-private-fountains.h
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_get-private-fountains.h
+ * @brief implementing GET /private/fountains request handling
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_H
+#define TALER_MERCHANT_HTTPD_GET_PRIVATE_FOUNTAINS_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a GET "/private/fountains" request.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_get_fountains (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c b/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,146 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c
+ * @brief implementing PATCH /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h"
+#include "taler-merchant-httpd_fountains.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/do_update_fountain.h"
+
+
+enum MHD_Result
+TMH_private_patch_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ const char *description = NULL;
+ struct GNUNET_TIME_Relative poll_freq;
+ bool no_poll_freq;
+ const json_t *jgrants = NULL;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("description",
+ &description),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_relative_time ("poll_freq",
+ &poll_freq),
+ &no_poll_freq),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_array_const ("grants",
+ &jgrants),
+ NULL),
+ GNUNET_JSON_spec_end ()
+ };
+ unsigned int grants_len = 0;
+ bool replace_grants;
+
+ GNUNET_assert (NULL != mi);
+ GNUNET_assert (NULL != hc->infix);
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ hc->request_body,
+ spec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+ replace_grants = (NULL != jgrants);
+ {
+ struct TALER_MERCHANTDB_FountainGrant grants[TMH_MAX_FOUNTAIN_GRANTS];
+ char *unknown_slug;
+ bool not_found;
+ enum GNUNET_DB_QueryStatus qs;
+
+ if (replace_grants)
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ res = TMH_fountain_grants_parse (connection,
+ jgrants,
+ grants,
+ &grants_len);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_JSON_parse_free (spec);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+ qs = TALER_MERCHANTDB_do_update_fountain (TMH_db,
+ mi->settings.id,
+ hc->infix,
+ description,
+ no_poll_freq
+ ? NULL
+ : &poll_freq,
+ replace_grants,
+ grants_len,
+ grants,
+ ¬_found,
+ &unknown_slug);
+ GNUNET_JSON_parse_free (spec);
+ if (qs < 0)
+ {
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "do_update_fountain");
+ }
+ if (not_found)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_FOUNTAIN_UNKNOWN,
+ hc->infix);
+ if (NULL != unknown_slug)
+ {
+ enum MHD_Result mret;
+
+ mret = TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN,
+ unknown_slug);
+ GNUNET_free (unknown_slug);
+ return mret;
+ }
+ }
+ return TALER_MHD_reply_static (connection,
+ MHD_HTTP_NO_CONTENT,
+ NULL,
+ NULL,
+ 0);
+}
+
+
+/* end of taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h b/src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_patch-private-fountains-FOUNTAIN_ID.h
+ * @brief implementing PATCH /private/fountains/$FOUNTAIN_ID request handling
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define TALER_MERCHANT_HTTPD_PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a PATCH "/private/fountains/$FOUNTAIN_ID" request.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_patch_fountains_FOUNTAIN_ID (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_post-fountain-withdraw.c b/src/backend/taler-merchant-httpd_post-fountain-withdraw.c
@@ -0,0 +1,1284 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_post-fountain-withdraw.c
+ * @brief implementing POST /fountain/withdraw request handling (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_post-fountain-withdraw.h"
+#include "taler-merchant-httpd_token-keys.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/do_fountain_withdraw.h"
+#include "merchant-database/get_fountain_by_secret.h"
+#include "merchant-database/get_fountain_withdraw.h"
+#include "merchant-database/get_token_family.h"
+#include "merchant-database/insert_fountain_withdraw_sig.h"
+#include "merchant-database/insert_issued_token.h"
+#include "merchant-database/iterate_fountain_grants.h"
+#include "merchant-database/start.h"
+
+
+/**
+ * Phases of processing a fountain withdrawal.
+ */
+enum WithdrawPhase
+{
+ /**
+ * Parse the request envelope.
+ */
+ WP_PARSE_REQUEST = 0,
+
+ /**
+ * Authenticate the fountain credential.
+ */
+ WP_AUTHENTICATE,
+
+ /**
+ * Start the withdrawal transaction.
+ */
+ WP_START_TRANSACTION,
+
+ /**
+ * Lock the fountain and restore any completed withdrawal.
+ */
+ WP_CHECK_REPLAY,
+
+ /**
+ * Load the current grants for a new withdrawal.
+ */
+ WP_LOAD_GRANTS,
+
+ /**
+ * Parse the requested tokens and check their grants.
+ */
+ WP_PARSE_ENTRIES,
+
+ /**
+ * Resolve issue keys and reject duplicate slots.
+ */
+ WP_RESOLVE_KEYS,
+
+ /**
+ * Check and consume the quota for all entries.
+ */
+ WP_CONSUME_QUOTA,
+
+ /**
+ * Sign tokens and store issued-token and replay records.
+ */
+ WP_SIGN_TOKENS,
+
+ /**
+ * Commit the withdrawal before sending its result.
+ */
+ WP_COMMIT_TRANSACTION,
+
+ /**
+ * Return the newly issued or restored signatures.
+ */
+ WP_SUCCESS_RESPONSE,
+
+ /**
+ * Return #MHD_YES to end processing.
+ */
+ WP_END_YES,
+
+ /**
+ * Return #MHD_NO to end processing.
+ */
+ WP_END_NO
+};
+
+
+/**
+ * A grant of the fountain the request authenticated as.
+ */
+struct GrantInfo
+{
+ /**
+ * Slug of the granted token family.
+ */
+ char *token_family_slug;
+
+ /**
+ * Serial of the granted token family.
+ */
+ uint64_t token_family_serial;
+
+ /**
+ * Number of issue-key slots ahead the wallet may withdraw for.
+ */
+ uint32_t key_window_size;
+};
+
+
+/**
+ * One entry of the withdraw request being processed.
+ */
+struct WithdrawEntry
+{
+ /**
+ * Slug of the token family withdrawn from; aliases into the
+ * request body.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Desired token validity time; selects a key from the grant's window.
+ */
+ struct GNUNET_TIME_Timestamp valid_at;
+
+ /**
+ * Blinded envelopes to sign.
+ */
+ struct TALER_TokenEnvelope *envelopes;
+
+ /**
+ * Length of the @e envelopes array.
+ */
+ unsigned int envelopes_len;
+
+ /**
+ * Serial of the token family.
+ */
+ uint64_t token_family_serial;
+
+ /**
+ * Issue key (and token family details) of the selected slot.
+ */
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd;
+
+ /**
+ * True if @e kd was initialized and must be released.
+ */
+ bool have_kd;
+};
+
+
+/**
+ * Request-specific context of a POST /fountain/withdraw request.
+ */
+struct WithdrawContext
+{
+ /**
+ * Connection to respond on.
+ */
+ struct MHD_Connection *connection;
+
+ /**
+ * Handler context, including the instance and request body.
+ */
+ struct TMH_HandlerContext *hc;
+
+ /**
+ * Current processing phase.
+ */
+ enum WithdrawPhase phase;
+
+ /**
+ * Fountain credential borrowed from the request body.
+ */
+ const char *fountain_secret;
+
+ /**
+ * JSON array of withdrawal entries borrowed from the request body.
+ */
+ const json_t *jentries;
+
+ /**
+ * Canonical hash identifying this withdrawal within the fountain.
+ */
+ struct GNUNET_HashCode h_request;
+
+ /**
+ * Grant results to return after replay or successful commit.
+ */
+ json_t *results;
+
+ /**
+ * True while this synchronous handler owns an open transaction.
+ */
+ bool transaction_open;
+
+ /**
+ * Grants of the fountain the request authenticated as.
+ */
+ struct GrantInfo *grants;
+
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int grants_len;
+
+ /**
+ * Entries of the request.
+ */
+ struct WithdrawEntry *entries;
+
+ /**
+ * Length of the @e entries array.
+ */
+ unsigned int entries_len;
+
+ /**
+ * Serial of the fountain the request authenticated as.
+ */
+ uint64_t fountain_serial;
+
+ /**
+ * Time this request is processed at.
+ */
+ struct GNUNET_TIME_Timestamp now;
+};
+
+
+/**
+ * Release the request-specific context.
+ *
+ * @param cls a `struct WithdrawContext *`
+ */
+static void
+withdraw_context_cleanup (void *cls)
+{
+ struct WithdrawContext *wc = cls;
+
+ for (unsigned int i = 0; i < wc->grants_len; i++)
+ GNUNET_free (wc->grants[i].token_family_slug);
+ GNUNET_array_grow (wc->grants,
+ wc->grants_len,
+ 0);
+ /* @e entries is only allocated once the request body was parsed;
+ @e entries_len is known before that. */
+ for (unsigned int i = 0; (NULL != wc->entries) && (i < wc->entries_len); i++)
+ {
+ struct WithdrawEntry *we = &wc->entries[i];
+
+ for (unsigned int j = 0; j < we->envelopes_len; j++)
+ if (NULL != we->envelopes[j].blinded_pub)
+ GNUNET_CRYPTO_blinded_message_decref (we->envelopes[j].blinded_pub);
+ GNUNET_free (we->envelopes);
+ if (we->have_kd)
+ TMH_token_key_details_free (&we->kd);
+ }
+ GNUNET_free (wc->entries);
+ json_decref (wc->results);
+ GNUNET_free (wc);
+}
+
+
+/**
+ * Add a grant of the fountain to the context in @a cls.
+ *
+ * @param cls a `struct WithdrawContext *`
+ * @param token_family_slug slug of the granted token family
+ * @param token_family_serial serial of the granted token family
+ * @param tokens_per_period_limit maximum withdrawals per period
+ * @param tokens_per_period_stash suggested tokens to hold per period
+ * @param key_window_size number of slots ahead withdrawals are allowed
+ */
+static void
+add_grant (void *cls,
+ const char *token_family_slug,
+ uint64_t token_family_serial,
+ uint64_t tokens_per_period_limit,
+ uint64_t tokens_per_period_stash,
+ uint32_t key_window_size)
+{
+ struct WithdrawContext *wc = cls;
+ struct GrantInfo gi = {
+ .token_family_slug = GNUNET_strdup (token_family_slug),
+ .token_family_serial = token_family_serial,
+ .key_window_size = key_window_size
+ };
+
+ (void) tokens_per_period_limit;
+ (void) tokens_per_period_stash;
+ GNUNET_array_append (wc->grants,
+ wc->grants_len,
+ gi);
+}
+
+
+/**
+ * Find the grant for @a slug among the grants of the fountain.
+ *
+ * @param wc context to search
+ * @param slug token family slug to look for
+ * @return NULL if the fountain does not grant @a slug
+ */
+static const struct GrantInfo *
+find_grant (const struct WithdrawContext *wc,
+ const char *slug)
+{
+ for (unsigned int i = 0; i < wc->grants_len; i++)
+ if (0 == strcmp (wc->grants[i].token_family_slug,
+ slug))
+ return &wc->grants[i];
+ return NULL;
+}
+
+
+/**
+ * Authenticate the request using the supplied fountain secret.
+ *
+ * @param[in,out] wc context to initialize
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+phase_authenticate (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const char *instance_id = wc->hc->instance->settings.id;
+ const char *fountain_secret = wc->fountain_secret;
+ struct GNUNET_HashCode h_secret;
+ struct GNUNET_TIME_Relative poll_freq;
+ enum GNUNET_DB_QueryStatus qs;
+
+ {
+ char secret[32];
+
+ if (GNUNET_OK !=
+ GNUNET_STRINGS_string_to_data (fountain_secret,
+ strlen (fountain_secret),
+ secret,
+ sizeof (secret)))
+ {
+ /* Reply as for an unknown credential, so that the endpoint does
+ not become an oracle for the shape of valid secrets. */
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_UNAUTHORIZED,
+ TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED,
+ "fountain secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ GNUNET_CRYPTO_hash (secret,
+ sizeof (secret),
+ &h_secret);
+ memset (secret,
+ 0,
+ sizeof (secret));
+ }
+ qs = TALER_MERCHANTDB_get_fountain_by_secret (TMH_db,
+ instance_id,
+ &h_secret,
+ &wc->fountain_serial,
+ &poll_freq);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_fountain_by_secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_UNAUTHORIZED,
+ TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED,
+ "fountain secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ wc->phase = WP_START_TRANSACTION;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Load current grants after locking the fountain and checking for a replay.
+ *
+ * @param[in,out] wc context to populate
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_load_grants (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const char *instance_id = wc->hc->instance->settings.id;
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_MERCHANTDB_iterate_fountain_grants (TMH_db,
+ instance_id,
+ wc->fountain_serial,
+ &add_grant,
+ wc);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "iterate_fountain_grants"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ wc->phase = WP_PARSE_ENTRIES;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Parse the envelopes of one request entry.
+ *
+ * @param connection connection to report errors on
+ * @param jenvelopes JSON array of token envelopes
+ * @param total_envelopes total number of envelopes parsed so far
+ * @param[in,out] we entry to complete
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+parse_envelopes (struct MHD_Connection *connection,
+ const json_t *jenvelopes,
+ unsigned int total_envelopes,
+ struct WithdrawEntry *we)
+{
+ unsigned int len = (unsigned int) json_array_size (jenvelopes);
+ size_t idx;
+ json_t *jev;
+
+ if ( (0 == len) ||
+ (total_envelopes + len > TMH_MAX_FOUNTAIN_ENVELOPES) )
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "'envelopes' empty or too many envelopes"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ we->envelopes = GNUNET_new_array (len,
+ struct TALER_TokenEnvelope);
+ we->envelopes_len = len;
+ json_array_foreach ((json_t *) jenvelopes, idx, jev)
+ {
+ struct GNUNET_JSON_Specification ispec[] = {
+ TALER_JSON_spec_token_envelope (NULL,
+ &we->envelopes[idx]),
+ GNUNET_JSON_spec_end ()
+ };
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ jev,
+ ispec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return res;
+ }
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Parse the entries of the request into @a wc, checking each against
+ * the grants of the fountain. Resolve keys after all envelopes are parsed.
+ *
+ * @param[in,out] wc context to complete
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+phase_parse_entries (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const json_t *jentries = wc->jentries;
+ unsigned int total_envelopes = 0;
+ size_t idx;
+ json_t *jentry;
+
+ wc->entries = GNUNET_new_array (wc->entries_len,
+ struct WithdrawEntry);
+ json_array_foreach ((json_t *) jentries, idx, jentry)
+ {
+ struct WithdrawEntry *we = &wc->entries[idx];
+ const json_t *jenvelopes;
+ const struct GrantInfo *gi;
+ enum GNUNET_GenericReturnValue res;
+ struct GNUNET_JSON_Specification espec[] = {
+ GNUNET_JSON_spec_string ("token_family_slug",
+ &we->token_family_slug),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_timestamp ("valid_at",
+ &we->valid_at),
+ NULL),
+ GNUNET_JSON_spec_array_const ("envelopes",
+ &jenvelopes),
+ GNUNET_JSON_spec_end ()
+ };
+
+ we->valid_at = wc->now;
+ res = TALER_MHD_parse_json_data (connection,
+ jentry,
+ espec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return res;
+ }
+ gi = find_grant (wc,
+ we->token_family_slug);
+ if (NULL == gi)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_GRANT_UNKNOWN,
+ we->token_family_slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ we->token_family_serial = gi->token_family_serial;
+ res = parse_envelopes (connection,
+ jenvelopes,
+ total_envelopes,
+ we);
+ if (GNUNET_OK != res)
+ return res;
+ total_envelopes += we->envelopes_len;
+ }
+ wc->phase = WP_RESOLVE_KEYS;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Select each entry's key from the same expiry-based window advertised
+ * by GET /fountain/info, and reject duplicate (token family, key) pairs.
+ *
+ * @param[in,out] wc context to complete
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+phase_resolve_keys (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const char *instance_id = wc->hc->instance->settings.id;
+
+ for (unsigned int i = 0; i < wc->entries_len; i++)
+ {
+ struct WithdrawEntry *we = &wc->entries[i];
+ const struct GrantInfo *gi;
+ struct TALER_MERCHANTDB_TokenFamilyDetails tf;
+ struct TMH_TokenKeyWindow window;
+ unsigned int key_index = 0;
+ enum GNUNET_GenericReturnValue res;
+ enum GNUNET_DB_QueryStatus qs;
+
+ gi = find_grant (wc,
+ we->token_family_slug);
+ GNUNET_assert (NULL != gi);
+ qs = TALER_MERCHANTDB_get_token_family (TMH_db,
+ instance_id,
+ we->token_family_slug,
+ &tf);
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_token_family"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ res = TMH_token_key_window_get (connection,
+ instance_id,
+ &tf,
+ wc->now,
+ gi->key_window_size,
+ &window);
+ TALER_MERCHANTDB_token_family_details_free (&tf);
+ if (GNUNET_OK != res)
+ return res;
+ if ( (0 == window.keys_len) ||
+ (GNUNET_OK !=
+ TMH_token_key_window_find (&window,
+ we->valid_at,
+ &key_index)) )
+ {
+ TMH_token_key_window_free (&window);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_SLOT_OUTSIDE_WINDOW,
+ we->token_family_slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ /* Transfer the selected key, rather than looking it up again by a
+ timestamp that an overlapping earlier key could also cover. */
+ we->kd = window.keys[key_index];
+ memset (&window.keys[key_index],
+ 0,
+ sizeof (window.keys[key_index]));
+ we->have_kd = true;
+ TMH_token_key_window_free (&window);
+ if (NULL == we->kd.priv.private_key)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "issue private key unavailable"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ for (unsigned int j = 0; j < we->envelopes_len; j++)
+ {
+ if (we->envelopes[j].blinded_pub->cipher !=
+ we->kd.priv.private_key->cipher)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "envelope cipher does not match issue key"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ }
+ for (unsigned int j = 0; j < i; j++)
+ {
+ if ( (wc->entries[j].token_family_serial ==
+ we->token_family_serial) &&
+ (GNUNET_TIME_timestamp_cmp (
+ wc->entries[j].kd.signature_validity_start,
+ ==,
+ we->kd.signature_validity_start)) )
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "multiple entries for one token family and key slot"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ }
+ }
+ wc->phase = WP_CONSUME_QUOTA;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Atomically check and consume the per-period withdrawal quota of
+ * all entries. Either the whole request fits, or nothing is
+ * consumed.
+ *
+ * @param[in,out] wc context of the request
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+phase_consume_quota (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const char *instance_id = wc->hc->instance->settings.id;
+ uint64_t family_serials[GNUNET_NZL (wc->entries_len)];
+ struct GNUNET_TIME_Timestamp slot_starts[GNUNET_NZL (wc->entries_len)];
+ uint64_t counts[GNUNET_NZL (wc->entries_len)];
+ unsigned int failed_index;
+ bool no_grant;
+ bool exceeded;
+ enum GNUNET_DB_QueryStatus qs;
+
+ for (unsigned int i = 0; i < wc->entries_len; i++)
+ {
+ family_serials[i] = wc->entries[i].token_family_serial;
+ slot_starts[i] = wc->entries[i].kd.signature_validity_start;
+ counts[i] = wc->entries[i].envelopes_len;
+ }
+ qs = TALER_MERCHANTDB_do_fountain_withdraw (TMH_db,
+ instance_id,
+ wc->fountain_serial,
+ wc->entries_len,
+ family_serials,
+ slot_starts,
+ counts,
+ &failed_index,
+ &no_grant,
+ &exceeded);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "do_fountain_withdraw"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (exceeded)
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_TOO_MANY_REQUESTS,
+ TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_LIMIT_EXCEEDED,
+ wc->entries[failed_index].token_family_slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ if (no_grant)
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_POST_FOUNTAIN_WITHDRAW_GRANT_UNKNOWN,
+ wc->entries[failed_index].token_family_slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ wc->phase = WP_SIGN_TOKENS;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Blind-sign the envelopes of all entries, record the issued tokens
+ * and construct the grant results. The caller must commit the quota,
+ * issued-token records and replay results together before responding.
+ *
+ * @param[in,out] wc context of the request
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_sign_tokens (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ const struct GNUNET_HashCode *h_request = &wc->h_request;
+ json_t *jresults;
+
+ jresults = json_array ();
+ GNUNET_assert (NULL != jresults);
+ for (unsigned int i = 0; i < wc->entries_len; i++)
+ {
+ const struct WithdrawEntry *we = &wc->entries[i];
+ struct TALER_TokenIssuePublicKeyHashP h_issue = {
+ .hash = we->kd.pub.public_key->pub_key_hash
+ };
+ json_t *jsigs;
+
+ jsigs = json_array ();
+ GNUNET_assert (NULL != jsigs);
+ for (unsigned int j = 0; j < we->envelopes_len; j++)
+ {
+ struct TALER_BlindedTokenIssueSignature sig;
+ enum GNUNET_DB_QueryStatus qs;
+ bool no_family;
+
+ TALER_token_issue_sign (&we->kd.priv,
+ &we->envelopes[j],
+ &sig);
+ if (NULL == sig.signature)
+ {
+ GNUNET_break (0);
+ json_decref (jsigs);
+ json_decref (jresults);
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "token_issue_sign"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ qs = TALER_MERCHANTDB_insert_issued_token (TMH_db,
+ NULL,
+ &h_issue,
+ &sig,
+ &no_family);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ GNUNET_CRYPTO_blinded_sig_decref (sig.signature);
+ json_decref (jsigs);
+ json_decref (jresults);
+ return (MHD_YES == TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "insert_issued_token"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (no_family)
+ {
+ /* The token family key was deleted after resolving the grant,
+ so we cannot issue this token anymore. */
+ GNUNET_break_op (0);
+ GNUNET_CRYPTO_blinded_sig_decref (sig.signature);
+ json_decref (jsigs);
+ json_decref (jresults);
+ return (MHD_YES == TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_TOKEN_KEY_UNKNOWN,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ qs = TALER_MERCHANTDB_insert_fountain_withdraw_sig (TMH_db,
+ wc->fountain_serial,
+ h_request,
+ i,
+ j,
+ &h_issue,
+ &sig);
+ if (0 >= qs)
+ {
+ GNUNET_break (0);
+ GNUNET_CRYPTO_blinded_sig_decref (sig.signature);
+ json_decref (jsigs);
+ json_decref (jresults);
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "insert_fountain_withdraw_sig"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jsigs,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_blinded_sig ("blind_sig",
+ sig.signature))));
+ GNUNET_CRYPTO_blinded_sig_decref (sig.signature);
+ }
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jresults,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ we->token_family_slug),
+ GNUNET_JSON_pack_data_auto ("h_issue",
+ &h_issue),
+ GNUNET_JSON_pack_array_steal ("token_sigs",
+ jsigs))));
+ }
+ wc->results = jresults;
+ wc->phase = WP_COMMIT_TRANSACTION;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #restore_grant_cb().
+ */
+struct RestoreState
+{
+ /**
+ * Grant results rebuilt so far.
+ */
+ json_t *grants;
+
+ /**
+ * Signatures of the grant currently being rebuilt.
+ */
+ json_t *sigs;
+
+ /**
+ * Slug of that grant.
+ */
+ char *slug;
+
+ /**
+ * Issue key hash of that grant.
+ */
+ struct TALER_TokenIssuePublicKeyHashP h_issue;
+
+ /**
+ * Index of that grant in the original request.
+ */
+ uint32_t grant_index;
+
+ /**
+ * True once a grant has been started, so @e slug and @e h_issue
+ * are meaningful.
+ */
+ bool started;
+};
+
+
+/**
+ * Append the grant currently being rebuilt to the results.
+ *
+ * @param[in,out] rs state to flush
+ */
+static void
+flush_grant (struct RestoreState *rs)
+{
+ if (! rs->started)
+ return;
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ rs->grants,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ rs->slug),
+ GNUNET_JSON_pack_data_auto ("h_issue",
+ &rs->h_issue),
+ GNUNET_JSON_pack_array_steal ("token_sigs",
+ rs->sigs))));
+ GNUNET_free (rs->slug);
+ rs->sigs = NULL;
+ rs->started = false;
+}
+
+
+/**
+ * Rebuild the response of an earlier withdrawal from its stored
+ * signatures. Rows arrive in response order, so a change of
+ * @a grant_index closes the grant being assembled.
+ *
+ * @param cls a `struct RestoreState *`
+ * @param grant_index offset of the grant in the original request
+ * @param token_family_slug token family of that grant
+ * @param h_issue issue key the signature was made with
+ * @param blind_sig the blind signature handed out originally
+ */
+static void
+restore_grant_cb (void *cls,
+ uint32_t grant_index,
+ const char *token_family_slug,
+ const struct TALER_TokenIssuePublicKeyHashP *h_issue,
+ const struct GNUNET_CRYPTO_BlindedSignature *blind_sig)
+{
+ struct RestoreState *rs = cls;
+
+ if ( (! rs->started) ||
+ (grant_index != rs->grant_index) )
+ {
+ flush_grant (rs);
+ rs->sigs = json_array ();
+ GNUNET_assert (NULL != rs->sigs);
+ rs->slug = GNUNET_strdup (token_family_slug);
+ rs->h_issue = *h_issue;
+ rs->grant_index = grant_index;
+ rs->started = true;
+ }
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ rs->sigs,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_blinded_sig (
+ "blind_sig",
+ (struct GNUNET_CRYPTO_BlindedSignature *) blind_sig))));
+}
+
+
+/**
+ * Parse the top-level request without inspecting the current grants.
+ *
+ * @param[in,out] wc request context
+ * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_parse_request (struct WithdrawContext *wc)
+{
+ struct MHD_Connection *connection = wc->connection;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("fountain_secret",
+ &wc->fountain_secret),
+ GNUNET_JSON_spec_array_const ("grants",
+ &wc->jentries),
+ GNUNET_JSON_spec_end ()
+ };
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ wc->hc->request_body,
+ spec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return res;
+ }
+ {
+ size_t len = json_array_size (wc->jentries);
+
+ if (len > TMH_MAX_FOUNTAIN_ENVELOPES)
+ {
+ GNUNET_break_op (0);
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "'grants' array too long"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ wc->entries_len = (unsigned int) len;
+ }
+ wc->phase = WP_AUTHENTICATE;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Start the transaction covering replay, quotas and token issuance.
+ *
+ * @param[in,out] wc request context
+ * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_start_transaction (struct WithdrawContext *wc)
+{
+ TALER_json_hash (wc->jentries,
+ &wc->h_request);
+ if (GNUNET_OK != TALER_MERCHANTDB_start_read_committed (
+ TMH_db,
+ "fountain withdraw"))
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ wc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_START_FAILED,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ wc->transaction_open = true;
+ wc->phase = WP_CHECK_REPLAY;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Lock the fountain and restore a completed withdrawal, if any.
+ *
+ * @param[in,out] wc request context
+ * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_check_replay (struct WithdrawContext *wc)
+{
+ struct RestoreState rs = { 0 };
+ enum GNUNET_DB_QueryStatus qs;
+ bool not_found;
+
+ /* Serialize withdrawals of this fountain. Replay before consulting
+ grants or key windows, which may have changed since the first call. */
+ rs.grants = json_array ();
+ GNUNET_assert (NULL != rs.grants);
+ qs = TALER_MERCHANTDB_get_fountain_withdraw (TMH_db,
+ wc->fountain_serial,
+ &wc->h_request,
+ ¬_found,
+ &restore_grant_cb,
+ &rs);
+ flush_grant (&rs);
+ if (qs < 0)
+ {
+ json_decref (rs.grants);
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ wc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_fountain_withdraw"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (not_found)
+ {
+ json_decref (rs.grants);
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ wc->connection,
+ MHD_HTTP_UNAUTHORIZED,
+ TALER_EC_MERCHANT_GENERIC_UNAUTHORIZED,
+ "fountain secret"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (0 != json_array_size (rs.grants))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ wc->transaction_open = false;
+ wc->results = rs.grants;
+ wc->phase = WP_SUCCESS_RESPONSE;
+ return GNUNET_OK;
+ }
+ json_decref (rs.grants);
+ wc->phase = WP_LOAD_GRANTS;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Commit quota, issued tokens and replay records together.
+ *
+ * @param[in,out] wc request context
+ * @return #GNUNET_OK to continue, #GNUNET_NO if an error was queued,
+ * #GNUNET_SYSERR if queueing failed
+ */
+static enum GNUNET_GenericReturnValue
+phase_commit_transaction (struct WithdrawContext *wc)
+{
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_MERCHANTDB_commit (TMH_db);
+ if (qs < 0)
+ {
+ return (MHD_YES == TALER_MHD_reply_with_error (
+ wc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ wc->transaction_open = false;
+ wc->phase = WP_SUCCESS_RESPONSE;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Return the newly issued or restored grant results.
+ *
+ * @param[in,out] wc request context
+ * @return #GNUNET_NO if the response was queued, #GNUNET_SYSERR otherwise
+ */
+static enum GNUNET_GenericReturnValue
+phase_success_response (struct WithdrawContext *wc)
+{
+ enum MHD_Result ret;
+
+ GNUNET_assert (! wc->transaction_open);
+ ret = TALER_MHD_REPLY_JSON_PACK (
+ wc->connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_array_steal ("grants",
+ wc->results));
+ wc->results = NULL;
+ return (MHD_YES == ret) ? GNUNET_NO : GNUNET_SYSERR;
+}
+
+
+enum MHD_Result
+TMH_post_fountain_withdraw (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct WithdrawContext *wc = hc->ctx;
+
+ GNUNET_assert (NULL != hc->instance);
+ if (NULL == wc)
+ {
+ wc = GNUNET_new (struct WithdrawContext);
+ wc->connection = connection;
+ wc->hc = hc;
+ wc->now = GNUNET_TIME_timestamp_get ();
+ hc->ctx = wc;
+ hc->cc = &withdraw_context_cleanup;
+ }
+ while (1)
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Processing /fountain/withdraw in phase %d\n",
+ (int) wc->phase);
+ switch (wc->phase)
+ {
+ case WP_PARSE_REQUEST:
+ res = phase_parse_request (wc);
+ break;
+ case WP_AUTHENTICATE:
+ res = phase_authenticate (wc);
+ break;
+ case WP_START_TRANSACTION:
+ res = phase_start_transaction (wc);
+ break;
+ case WP_CHECK_REPLAY:
+ res = phase_check_replay (wc);
+ break;
+ case WP_LOAD_GRANTS:
+ res = phase_load_grants (wc);
+ break;
+ case WP_PARSE_ENTRIES:
+ res = phase_parse_entries (wc);
+ break;
+ case WP_RESOLVE_KEYS:
+ res = phase_resolve_keys (wc);
+ break;
+ case WP_CONSUME_QUOTA:
+ res = phase_consume_quota (wc);
+ break;
+ case WP_SIGN_TOKENS:
+ res = phase_sign_tokens (wc);
+ break;
+ case WP_COMMIT_TRANSACTION:
+ res = phase_commit_transaction (wc);
+ break;
+ case WP_SUCCESS_RESPONSE:
+ res = phase_success_response (wc);
+ break;
+ case WP_END_YES:
+ return MHD_YES;
+ case WP_END_NO:
+ return MHD_NO;
+ default:
+ GNUNET_assert (0);
+ return MHD_NO;
+ }
+ if (GNUNET_OK != res)
+ {
+ /* All phases run synchronously: release the transaction before
+ returning control to the HTTP server, including on queueing errors. */
+ if (wc->transaction_open)
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ wc->transaction_open = false;
+ }
+ wc->phase = (GNUNET_NO == res) ? WP_END_YES : WP_END_NO;
+ }
+ }
+}
+
+
+/* end of taler-merchant-httpd_post-fountain-withdraw.c */
diff --git a/src/backend/taler-merchant-httpd_post-fountain-withdraw.h b/src/backend/taler-merchant-httpd_post-fountain-withdraw.h
@@ -0,0 +1,47 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_post-fountain-withdraw.h
+ * @brief implementing POST /fountain/withdraw request handling (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_POST_FOUNTAIN_WITHDRAW_H
+#define TALER_MERCHANT_HTTPD_POST_FOUNTAIN_WITHDRAW_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle a POST "/fountain/withdraw" request. Public endpoint,
+ * authenticated by the fountain's bearer credential in the request
+ * body. Blind-signs the submitted token envelopes within the
+ * fountain's per-period withdrawal limits.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_post_fountain_withdraw (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_post-private-fountains.c b/src/backend/taler-merchant-httpd_post-private-fountains.c
@@ -0,0 +1,186 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_post-private-fountains.c
+ * @brief implementing POST /private/fountains request handling
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_post-private-fountains.h"
+#include "taler-merchant-httpd_helper.h"
+#include "taler-merchant-httpd_fountains.h"
+#include <taler/taler_json_lib.h>
+#include "merchant-database/do_insert_fountain.h"
+
+
+/**
+ * How often do we retry on a (astronomically unlikely) random
+ * collision of the fountain identifier or secret?
+ */
+#define MAX_RETRIES 3
+
+
+enum MHD_Result
+TMH_private_post_fountains (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ struct TMH_MerchantInstance *mi = hc->instance;
+ const char *description;
+ struct GNUNET_TIME_Relative poll_freq;
+ const json_t *jgrants;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("description",
+ &description),
+ GNUNET_JSON_spec_relative_time ("poll_freq",
+ &poll_freq),
+ GNUNET_JSON_spec_array_const ("grants",
+ &jgrants),
+ GNUNET_JSON_spec_end ()
+ };
+ unsigned int grants_len;
+
+ GNUNET_assert (NULL != mi);
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ hc->request_body,
+ spec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+ {
+ struct TALER_MERCHANTDB_FountainGrant grants[TMH_MAX_FOUNTAIN_GRANTS];
+ enum GNUNET_GenericReturnValue res;
+
+ res = TMH_fountain_grants_parse (connection,
+ jgrants,
+ grants,
+ &grants_len);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_JSON_parse_free (spec);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+
+ for (unsigned int attempt = 0; attempt < MAX_RETRIES; attempt++)
+ {
+ char secret[32];
+ struct GNUNET_HashCode h_secret;
+ char *fountain_id;
+ char *fountain_secret;
+ char *unknown_slug;
+ bool conflict;
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_CRYPTO_random_block (secret,
+ sizeof (secret));
+ GNUNET_CRYPTO_hash (secret,
+ sizeof (secret),
+ &h_secret);
+ {
+ char rnd[16];
+
+ GNUNET_CRYPTO_random_block (rnd,
+ sizeof (rnd));
+ fountain_id = GNUNET_STRINGS_data_to_string_alloc (rnd,
+ sizeof (rnd));
+ }
+ qs = TALER_MERCHANTDB_do_insert_fountain (TMH_db,
+ mi->settings.id,
+ fountain_id,
+ &h_secret,
+ description,
+ poll_freq,
+ grants_len,
+ grants,
+ &unknown_slug,
+ &conflict);
+ if (qs < 0)
+ {
+ GNUNET_break (0);
+ GNUNET_free (fountain_id);
+ GNUNET_JSON_parse_free (spec);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "do_insert_fountain");
+ }
+ if (NULL != unknown_slug)
+ {
+ enum MHD_Result mret;
+
+ GNUNET_free (fountain_id);
+ GNUNET_JSON_parse_free (spec);
+ mret = TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN,
+ unknown_slug);
+ GNUNET_free (unknown_slug);
+ return mret;
+ }
+ if (conflict)
+ {
+ /* Random collision of fountain_id or secret hash; try again
+ with fresh randomness. */
+ GNUNET_free (fountain_id);
+ continue;
+ }
+ fountain_secret = GNUNET_STRINGS_data_to_string_alloc (secret,
+ sizeof (secret));
+ memset (secret,
+ 0,
+ sizeof (secret));
+ GNUNET_JSON_parse_free (spec);
+ {
+ enum MHD_Result mret;
+
+ mret = TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_string ("fountain_id",
+ fountain_id),
+ GNUNET_JSON_pack_string ("fountain_secret",
+ fountain_secret));
+ GNUNET_free (fountain_id);
+ GNUNET_free (fountain_secret);
+ return mret;
+ }
+ }
+ }
+ GNUNET_break (0);
+ GNUNET_JSON_parse_free (spec);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "repeated fountain id collision");
+}
+
+
+/* end of taler-merchant-httpd_post-private-fountains.c */
diff --git a/src/backend/taler-merchant-httpd_post-private-fountains.h b/src/backend/taler-merchant-httpd_post-private-fountains.h
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation; either version 3,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not,
+ see <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/backend/taler-merchant-httpd_post-private-fountains.h
+ * @brief implementing POST /private/fountains request handling
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_POST_PRIVATE_FOUNTAINS_H
+#define TALER_MERCHANT_HTTPD_POST_PRIVATE_FOUNTAINS_H
+
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Create a fountain (DD 98).
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_post_fountains (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_post-private-orders.c b/src/backend/taler-merchant-httpd_post-private-orders.c
@@ -42,6 +42,7 @@
#include "taler-merchant-httpd.h"
#include "taler-merchant-httpd_exchanges.h"
#include "taler-merchant-httpd_post-private-orders.h"
+#include "taler-merchant-httpd_token-keys.h"
#include "taler-merchant-httpd_get-exchanges.h"
#include "taler-merchant-httpd_contract.h"
#include "taler-merchant-httpd_helper.h"
@@ -55,12 +56,9 @@
#include "merchant-database/get_missing_money_pot.h"
#include "merchant-database/insert_order.h"
#include "merchant-database/insert_order_lock.h"
-#include "merchant-database/insert_token_family_key.h"
#include "merchant-database/get_order.h"
#include "merchant-database/get_order_summary.h"
#include "merchant-database/get_product.h"
-#include "merchant-database/get_token_family_key.h"
-#include "merchant-database/update_token_family_key_expiration.h"
#include "merchant-database/iterate_token_family_keys.h"
#include "merchant-database/iterate_donau_instances_filtered.h"
#include "merchant-database/get_otp_device.h"
@@ -1418,68 +1416,6 @@ phase_salt_forgettable (struct OrderContext *oc)
/* ***************** ORDER_PHASE_SERIALIZE_ORDER **************** */
/**
- * Get rounded time interval. @a start is calculated by rounding
- * @a ts down to the nearest multiple of @a precision.
- *
- * @param precision rounding precision.
- * year, month, day, hour, minute are supported.
- * @param ts timestamp to round
- * @param[out] start start of the interval
- * @return #GNUNET_OK on success, #GNUNET_SYSERR on error
- */
-static enum GNUNET_GenericReturnValue
-get_rounded_time_interval_down (struct GNUNET_TIME_Relative precision,
- struct GNUNET_TIME_Timestamp ts,
- struct GNUNET_TIME_Timestamp *start)
-{
- enum GNUNET_TIME_RounderInterval ri;
-
- ri = GNUNET_TIME_relative_to_round_interval (precision);
- if ( (GNUNET_TIME_RI_NONE == ri) &&
- (! GNUNET_TIME_relative_is_zero (precision)) )
- {
- *start = ts;
- return GNUNET_SYSERR;
- }
- *start = GNUNET_TIME_absolute_to_timestamp (
- GNUNET_TIME_round_down (ts.abs_time,
- ri));
- return GNUNET_OK;
-}
-
-
-/**
- * Get rounded time interval. @a start is calculated by rounding
- * @a ts up to the nearest multiple of @a precision.
- *
- * @param precision rounding precision.
- * year, month, day, hour, minute are supported.
- * @param ts timestamp to round
- * @param[out] start start of the interval
- * @return #GNUNET_OK on success, #GNUNET_SYSERR on error
- */
-static enum GNUNET_GenericReturnValue
-get_rounded_time_interval_up (struct GNUNET_TIME_Relative precision,
- struct GNUNET_TIME_Timestamp ts,
- struct GNUNET_TIME_Timestamp *start)
-{
- enum GNUNET_TIME_RounderInterval ri;
-
- ri = GNUNET_TIME_relative_to_round_interval (precision);
- if ( (GNUNET_TIME_RI_NONE == ri) &&
- (! GNUNET_TIME_relative_is_zero (precision)) )
- {
- *start = ts;
- return GNUNET_SYSERR;
- }
- *start = GNUNET_TIME_absolute_to_timestamp (
- GNUNET_TIME_round_up (ts.abs_time,
- ri));
- return GNUNET_OK;
-}
-
-
-/**
* Find the family entry for the family of the given @a slug
* in @a oc.
*
@@ -1526,74 +1462,10 @@ add_family_key (void *cls,
if (NULL == family)
{
/* Family not yet in our contract terms, create new entry */
- struct TALER_MERCHANT_ContractTokenFamily new_family = {
- .slug = GNUNET_strdup (tf->slug),
- .name = GNUNET_strdup (tf->name),
- .description = GNUNET_strdup (tf->description),
- .description_i18n = json_incref (tf->description_i18n),
- };
-
- switch (tf->kind)
- {
- case TALER_MERCHANTDB_TFK_Subscription:
- {
- json_t *tdomains = json_object_get (tf->extra_data,
- "trusted_domains");
- json_t *dom;
- size_t i;
-
- new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_SUBSCRIPTION;
- new_family.critical = true;
- new_family.details.subscription.trusted_domains_len
- = json_array_size (tdomains);
- GNUNET_assert (new_family.details.subscription.trusted_domains_len
- < UINT_MAX);
- new_family.details.subscription.trusted_domains
- = GNUNET_new_array (
- new_family.details.subscription.trusted_domains_len,
- char *);
- json_array_foreach (tdomains, i, dom)
- {
- const char *val;
+ struct TALER_MERCHANT_ContractTokenFamily new_family;
- val = json_string_value (dom);
- GNUNET_break (NULL != val);
- if (NULL != val)
- new_family.details.subscription.trusted_domains[i]
- = GNUNET_strdup (val);
- }
- break;
- }
- case TALER_MERCHANTDB_TFK_Discount:
- {
- json_t *edomains = json_object_get (tf->extra_data,
- "expected_domains");
- json_t *dom;
- size_t i;
-
- new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_DISCOUNT;
- new_family.critical = false;
- new_family.details.discount.expected_domains_len
- = json_array_size (edomains);
- GNUNET_assert (new_family.details.discount.expected_domains_len
- < UINT_MAX);
- new_family.details.discount.expected_domains
- = GNUNET_new_array (
- new_family.details.discount.expected_domains_len,
- char *);
- json_array_foreach (edomains, i, dom)
- {
- const char *val;
-
- val = json_string_value (dom);
- GNUNET_break (NULL != val);
- if (NULL != val)
- new_family.details.discount.expected_domains[i]
- = GNUNET_strdup (val);
- }
- break;
- }
- }
+ TMH_token_family_to_contract (tf,
+ &new_family);
GNUNET_array_append (oc->parse_choices.token_families,
oc->parse_choices.token_families_len,
new_family);
@@ -1717,48 +1589,6 @@ find_key_index (struct TALER_MERCHANT_ContractTokenFamily *family,
/**
- * Create fresh key pair based on @a cipher_spec.
- *
- * @param cipher_spec which kind of key pair should we generate
- * @param[out] priv set to new private key
- * @param[out] pub set to new public key
- * @return #GNUNET_OK on success
- */
-static enum GNUNET_GenericReturnValue
-create_key (const char *cipher_spec,
- struct TALER_TokenIssuePrivateKey *priv,
- struct TALER_TokenIssuePublicKey *pub)
-{
- unsigned int len;
- char dummy;
-
- if (0 == strcmp ("cs",
- cipher_spec))
- {
- GNUNET_CRYPTO_blind_sign_keys_create (
- &priv->private_key,
- &pub->public_key,
- GNUNET_CRYPTO_BSA_CS);
- return GNUNET_OK;
- }
- if (1 ==
- sscanf (cipher_spec,
- "rsa(%u)%c",
- &len,
- &dummy))
- {
- GNUNET_CRYPTO_blind_sign_keys_create (
- &priv->private_key,
- &pub->public_key,
- GNUNET_CRYPTO_BSA_RSA,
- len);
- return GNUNET_OK;
- }
- return GNUNET_SYSERR;
-}
-
-
-/**
* Check if the token family with the given @a slug is already present in the
* list of token families for this order. If not, fetch its details and add it
* to the list. Also checks if there is a public key with that expires after
@@ -1782,43 +1612,9 @@ add_output_token_family (struct OrderContext *oc,
{
struct TALER_MERCHANTDB_TokenFamilyKeyDetails key_details;
struct TALER_MERCHANT_ContractTokenFamily *family;
- enum GNUNET_DB_QueryStatus qs;
+ enum GNUNET_GenericReturnValue res;
+ bool minted;
- /* We are about to promise a token of this family, so the private key
- covering @a valid_at must survive until we sign at the pay deadline. If
- an existing key covers the validity period but was minted for an order
- with an earlier pay deadline, extend its lifetime instead of minting a
- second key for the very same validity period: the key lookups below (and
- find_key_index()) would otherwise consider that key missing and we would
- end up listing two keys for one validity period in the contract terms. */
- qs = TALER_MERCHANTDB_update_token_family_key_expiration (
- TMH_db,
- oc->hc->instance->settings.id,
- slug,
- valid_at,
- oc->parse_order.order->pay_deadline);
- switch (qs)
- {
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- "update_token_family_key_expiration");
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SOFT_ERROR:
- /* Single-statement transaction shouldn't possibly cause serialization errors.
- Thus treating like a hard error. */
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_SOFT_FAILURE,
- "update_token_family_key_expiration");
- return GNUNET_SYSERR;
- default:
- /* No key needed extending, or one/more were extended; either is fine. */
- break;
- }
family = find_family (oc,
slug);
if ( (NULL != family) &&
@@ -1826,243 +1622,55 @@ add_output_token_family (struct OrderContext *oc,
find_key_index (family,
valid_at,
key_index)) )
+ {
+ /* Even a key already in the contract must remain usable until
+ this order's payment deadline. */
+ res = TMH_token_key_extend (oc->connection,
+ oc->hc->instance->settings.id,
+ slug,
+ valid_at,
+ oc->parse_order.order->pay_deadline);
+ if (GNUNET_OK != res)
+ {
+ finalize_order2 (oc,
+ res);
+ return GNUNET_SYSERR;
+ }
return GNUNET_OK;
- qs = TALER_MERCHANTDB_get_token_family_key (
- TMH_db,
- oc->hc->instance->settings.id,
- slug,
- valid_at,
- oc->parse_order.order->pay_deadline,
- &key_details);
- switch (qs)
+ }
+ res = TMH_token_key_ensure (oc->connection,
+ oc->hc->instance->settings.id,
+ slug,
+ valid_at,
+ oc->parse_order.order->pay_deadline,
+ &key_details,
+ &minted);
+ if (GNUNET_OK != res)
{
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_FETCH_FAILED,
- "get_token_family_key");
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SOFT_ERROR:
- /* Single-statement transaction shouldn't possibly cause serialization errors.
- Thus treating like a hard error. */
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_SOFT_FAILURE,
- "get_token_family_key");
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
- "Output token family slug %s unknown at %llu for %llu for instance %s\n",
- slug,
- (unsigned long long) valid_at.abs_time.abs_value_us,
- (unsigned long long) oc->parse_order.order->pay_deadline.abs_time.abs_value_us,
- oc->hc->instance->settings.id);
- reply_with_error (oc,
- MHD_HTTP_NOT_FOUND,
- TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN,
- slug);
+ finalize_order2 (oc,
+ res);
return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
- break;
}
-
- GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Lookup of token family %s at %llu yielded %s\n",
- slug,
- (unsigned long long) valid_at.abs_time.abs_value_us,
- NULL == key_details.pub.public_key ? "no key" : "a key");
-
- /* add_family_key() must run even if the family already exists, else a
- DB-only key would never reach the in-memory family and the
- find_key_index() assertion below aborts the backend (SIGABRT). */
+ /* Add the result even if the family is already in the contract:
+ the database may have returned a key not yet listed there. */
add_family_key (oc,
&key_details);
- if (NULL == family)
+ TMH_token_key_details_free (&key_details);
+ family = find_family (oc,
+ slug);
+ GNUNET_assert (NULL != family);
+ if (minted)
{
- family = find_family (oc,
- slug);
- GNUNET_assert (NULL != family);
+ /* Preserve the order path's choice of the newly appended key,
+ even if an older key covers an overlapping validity period. */
+ *key_index = family->keys_len - 1;
}
- /* we don't need the full family details anymore */
- GNUNET_free (key_details.token_family.slug);
- GNUNET_free (key_details.token_family.name);
- GNUNET_free (key_details.token_family.description);
- json_decref (key_details.token_family.description_i18n);
- json_decref (key_details.token_family.extra_data);
-
- if (NULL != key_details.pub.public_key)
+ else
{
- /* get_token_family_key must have found a matching key,
- and it must have been added. Find and use the index. */
- GNUNET_CRYPTO_blind_sign_pub_decref (key_details.pub.public_key);
- GNUNET_CRYPTO_blind_sign_priv_decref (key_details.priv.private_key);
- GNUNET_free (key_details.token_family.cipher_spec);
GNUNET_assert (GNUNET_OK ==
find_key_index (family,
valid_at,
key_index));
- return GNUNET_OK;
- }
-
- /* No suitable key exists, create one! */
- {
- struct TALER_MERCHANT_ContractTokenFamilyKey key;
- enum GNUNET_DB_QueryStatus iqs;
- struct TALER_TokenIssuePrivateKey token_priv;
- struct GNUNET_TIME_Timestamp key_expires;
- struct GNUNET_TIME_Timestamp round_start;
-
- if (GNUNET_OK !=
- get_rounded_time_interval_down (
- key_details.token_family.validity_granularity,
- GNUNET_TIME_absolute_to_timestamp (
- GNUNET_TIME_absolute_subtract (
- valid_at.abs_time,
- key_details.token_family.start_offset)),
- &round_start))
- {
- GNUNET_break (0);
- GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
- "Unsupported validity granularity interval %s found in database for token family %s!\n",
- GNUNET_TIME_relative2s (
- key_details.token_family.validity_granularity,
- false),
- slug);
- GNUNET_free (key_details.token_family.cipher_spec);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
- "get_rounded_time_interval_down failed");
- return GNUNET_SYSERR;
- }
- if (GNUNET_TIME_relative_cmp (
- key_details.token_family.duration,
- <,
- GNUNET_TIME_relative_add (
- key_details.token_family.validity_granularity,
- key_details.token_family.start_offset)))
- {
- GNUNET_break (0);
- GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
- "Inconsistent duration %s found in database for token family %s (below validity granularity plus start_offset)!\n",
- GNUNET_TIME_relative2s (key_details.token_family.duration,
- false),
- slug);
- GNUNET_free (key_details.token_family.cipher_spec);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
- "duration, validity_granularity and start_offset inconsistent for token family");
- return GNUNET_SYSERR;
- }
- key.valid_after
- = GNUNET_TIME_timestamp_max (
- GNUNET_TIME_absolute_to_timestamp (
- GNUNET_TIME_absolute_subtract (
- round_start.abs_time,
- key_details.token_family.start_offset)),
- key_details.token_family.valid_after);
- key.valid_before
- = GNUNET_TIME_timestamp_min (
- GNUNET_TIME_absolute_to_timestamp (
- GNUNET_TIME_absolute_add (
- key.valid_after.abs_time,
- key_details.token_family.duration)),
- key_details.token_family.valid_before);
- GNUNET_assert (GNUNET_OK ==
- get_rounded_time_interval_down (
- key_details.token_family.validity_granularity,
- key.valid_before,
- &key_expires));
- /* Make sure key never expires before the payment deadline */
- key_expires = GNUNET_TIME_timestamp_max (
- oc->parse_order.order->pay_deadline,
- key_expires);
- if (GNUNET_TIME_timestamp_cmp (
- key_expires,
- ==,
- round_start))
- {
- /* valid_before does not actually end after the
- next rounded validity period would start;
- determine next rounded validity period
- start point and extend valid_before to cover
- the full validity period */
- GNUNET_assert (
- GNUNET_OK ==
- get_rounded_time_interval_up (
- key_details.token_family.validity_granularity,
- key.valid_before,
- &key_expires));
- /* This should basically always end up being key_expires */
- key.valid_before = GNUNET_TIME_timestamp_max (key.valid_before,
- key_expires);
- }
- if (GNUNET_OK !=
- create_key (key_details.token_family.cipher_spec,
- &token_priv,
- &key.pub))
- {
- GNUNET_break (0);
- GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
- "Unsupported cipher family %s found in database for token family %s!\n",
- key_details.token_family.cipher_spec,
- slug);
- GNUNET_free (key_details.token_family.cipher_spec);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
- "invalid cipher stored in local database for token family");
- return GNUNET_SYSERR;
- }
- GNUNET_free (key_details.token_family.cipher_spec);
- GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Storing new key for slug %s of %s\n",
- slug,
- oc->hc->instance->settings.id);
- iqs = TALER_MERCHANTDB_insert_token_family_key (TMH_db,
- oc->hc->instance->settings.id,
- slug,
- &key.pub,
- &token_priv,
- key_expires,
- key.valid_after,
- key.valid_before);
- GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key);
- switch (iqs)
- {
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- NULL);
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SOFT_ERROR:
- /* Single-statement transaction shouldn't possibly cause serialization errors.
- Thus treating like a hard error. */
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_SOFT_FAILURE,
- NULL);
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- GNUNET_break (0);
- reply_with_error (oc,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- NULL);
- return GNUNET_SYSERR;
- case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
- break;
- }
- *key_index = family->keys_len;
- GNUNET_array_append (family->keys,
- family->keys_len,
- key);
}
return GNUNET_OK;
}
diff --git a/src/backend/taler-merchant-httpd_token-keys.c b/src/backend/taler-merchant-httpd_token-keys.c
@@ -0,0 +1,833 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2024-2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/backend/taler-merchant-httpd_token-keys.c
+ * @brief shared token family key lookup, lifetime extension and creation
+ * for orders and fountains; also provides fountain key windows
+ * @author Christian Blättler
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include "taler-merchant-httpd_token-keys.h"
+#include "merchant-database/insert_token_family_key.h"
+#include "merchant-database/update_token_family_key_expiration.h"
+#include "merchant-database/start.h"
+
+
+/**
+ * Get rounded time interval. @a start is calculated by rounding
+ * @a ts down to the nearest multiple of @a precision.
+ *
+ * @param precision rounding precision.
+ * year, month, day, hour, minute are supported.
+ * @param ts timestamp to round
+ * @param[out] start start of the interval
+ * @return #GNUNET_OK on success, #GNUNET_SYSERR on error
+ */
+static enum GNUNET_GenericReturnValue
+get_rounded_time_interval_down (struct GNUNET_TIME_Relative precision,
+ struct GNUNET_TIME_Timestamp ts,
+ struct GNUNET_TIME_Timestamp *start)
+{
+ enum GNUNET_TIME_RounderInterval ri;
+
+ ri = GNUNET_TIME_relative_to_round_interval (precision);
+ if ( (GNUNET_TIME_RI_NONE == ri) &&
+ (! GNUNET_TIME_relative_is_zero (precision)) )
+ {
+ *start = ts;
+ return GNUNET_SYSERR;
+ }
+ *start = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_round_down (ts.abs_time,
+ ri));
+ return GNUNET_OK;
+}
+
+
+/**
+ * Get rounded time interval. @a start is calculated by rounding
+ * @a ts up to the nearest multiple of @a precision.
+ *
+ * @param precision rounding precision.
+ * year, month, day, hour, minute are supported.
+ * @param ts timestamp to round
+ * @param[out] start start of the interval
+ * @return #GNUNET_OK on success, #GNUNET_SYSERR on error
+ */
+static enum GNUNET_GenericReturnValue
+get_rounded_time_interval_up (struct GNUNET_TIME_Relative precision,
+ struct GNUNET_TIME_Timestamp ts,
+ struct GNUNET_TIME_Timestamp *start)
+{
+ enum GNUNET_TIME_RounderInterval ri;
+
+ ri = GNUNET_TIME_relative_to_round_interval (precision);
+ if ( (GNUNET_TIME_RI_NONE == ri) &&
+ (! GNUNET_TIME_relative_is_zero (precision)) )
+ {
+ *start = ts;
+ return GNUNET_SYSERR;
+ }
+ *start = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_round_up (ts.abs_time,
+ ri));
+ return GNUNET_OK;
+}
+
+
+/**
+ * Create fresh key pair based on @a cipher_spec.
+ *
+ * @param cipher_spec which kind of key pair should we generate
+ * @param[out] priv set to new private key
+ * @param[out] pub set to new public key
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+create_key (const char *cipher_spec,
+ struct TALER_TokenIssuePrivateKey *priv,
+ struct TALER_TokenIssuePublicKey *pub)
+{
+ unsigned int len;
+ char dummy;
+
+ if (0 == strcmp ("cs",
+ cipher_spec))
+ {
+ GNUNET_CRYPTO_blind_sign_keys_create (
+ &priv->private_key,
+ &pub->public_key,
+ GNUNET_CRYPTO_BSA_CS);
+ return GNUNET_OK;
+ }
+ if (1 ==
+ sscanf (cipher_spec,
+ "rsa(%u)%c",
+ &len,
+ &dummy))
+ {
+ GNUNET_CRYPTO_blind_sign_keys_create (
+ &priv->private_key,
+ &pub->public_key,
+ GNUNET_CRYPTO_BSA_RSA,
+ len);
+ return GNUNET_OK;
+ }
+ return GNUNET_SYSERR;
+}
+
+
+void
+TMH_token_key_details_free (
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details)
+{
+ GNUNET_free (key_details->token_family.slug);
+ GNUNET_free (key_details->token_family.name);
+ GNUNET_free (key_details->token_family.description);
+ json_decref (key_details->token_family.description_i18n);
+ key_details->token_family.description_i18n = NULL;
+ json_decref (key_details->token_family.extra_data);
+ key_details->token_family.extra_data = NULL;
+ GNUNET_free (key_details->token_family.cipher_spec);
+ if (NULL != key_details->pub.public_key)
+ {
+ GNUNET_CRYPTO_blind_sign_pub_decref (key_details->pub.public_key);
+ key_details->pub.public_key = NULL;
+ }
+ if (NULL != key_details->priv.private_key)
+ {
+ GNUNET_CRYPTO_blind_sign_priv_decref (key_details->priv.private_key);
+ key_details->priv.private_key = NULL;
+ }
+}
+
+
+/**
+ * Create, store and return a fresh issue key covering @a valid_at
+ * for the token family described by @a key_details.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance owning the token family
+ * @param slug slug of the token family
+ * @param valid_at time the new key must cover
+ * @param sign_until how long the private key must remain usable
+ * @param require_coverage if true, leave the key unset when the existing
+ * validity rules cannot cover @a valid_at within the family bounds
+ * @param[in,out] key_details token family details on entry; the new
+ * key and its validity bounds are added on success
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+static enum GNUNET_GenericReturnValue
+mint_key (struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until,
+ bool require_coverage,
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details)
+{
+ struct TALER_MERCHANT_ContractTokenFamilyKey key;
+ enum GNUNET_DB_QueryStatus iqs;
+ struct TALER_TokenIssuePrivateKey token_priv;
+ struct GNUNET_TIME_Timestamp key_expires;
+ struct GNUNET_TIME_Timestamp round_start;
+
+ /* Offset the rounded period once. Subtracting the offset before rounding
+ as well can select an already expired period, even when duration is at
+ least granularity + offset. Existing covering keys are reused by lookup. */
+ if (GNUNET_OK !=
+ get_rounded_time_interval_down (
+ key_details->token_family.validity_granularity,
+ valid_at,
+ &round_start))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unsupported validity granularity interval %s found in database for token family %s!\n",
+ GNUNET_TIME_relative2s (
+ key_details->token_family.validity_granularity,
+ false),
+ slug);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "get_rounded_time_interval_down failed"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (GNUNET_TIME_relative_cmp (
+ key_details->token_family.duration,
+ <,
+ GNUNET_TIME_relative_add (
+ key_details->token_family.validity_granularity,
+ key_details->token_family.start_offset)))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Inconsistent duration %s found in database for token family %s (below validity granularity plus start_offset)!\n",
+ GNUNET_TIME_relative2s (key_details->token_family.duration,
+ false),
+ slug);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "duration, validity_granularity and start_offset inconsistent for token family"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ key.valid_after
+ = GNUNET_TIME_timestamp_max (
+ GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_subtract (
+ round_start.abs_time,
+ key_details->token_family.start_offset)),
+ key_details->token_family.valid_after);
+ key.valid_before
+ = GNUNET_TIME_timestamp_min (
+ GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (
+ key.valid_after.abs_time,
+ key_details->token_family.duration)),
+ key_details->token_family.valid_before);
+ GNUNET_assert (GNUNET_OK ==
+ get_rounded_time_interval_down (
+ key_details->token_family.validity_granularity,
+ key.valid_before,
+ &key_expires));
+ /* Make sure key never expires before @a sign_until */
+ key_expires = GNUNET_TIME_timestamp_max (
+ sign_until,
+ key_expires);
+ if (GNUNET_TIME_timestamp_cmp (
+ key_expires,
+ ==,
+ round_start))
+ {
+ /* valid_before does not actually end after the
+ next rounded validity period would start;
+ determine next rounded validity period
+ start point and extend valid_before to cover
+ the full validity period */
+ GNUNET_assert (
+ GNUNET_OK ==
+ get_rounded_time_interval_up (
+ key_details->token_family.validity_granularity,
+ key.valid_before,
+ &key_expires));
+ /* This should basically always end up being key_expires */
+ key.valid_before = GNUNET_TIME_timestamp_max (key.valid_before,
+ key_expires);
+ }
+ /* Fountains must not advertise a key that fails to advance the
+ coverage chain. Check before generating or storing anything. The
+ order path retains its existing validity semantics. */
+ if (require_coverage &&
+ (GNUNET_TIME_timestamp_cmp (key.valid_after, >, valid_at) ||
+ GNUNET_TIME_timestamp_cmp (key.valid_before, <, valid_at) ||
+ GNUNET_TIME_timestamp_cmp (key.valid_before,
+ >,
+ key_details->token_family.valid_before)))
+ return GNUNET_OK;
+ if (GNUNET_OK !=
+ create_key (key_details->token_family.cipher_spec,
+ &token_priv,
+ &key.pub))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unsupported cipher family %s found in database for token family %s!\n",
+ key_details->token_family.cipher_spec,
+ slug);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "invalid cipher stored in local database for token family"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Storing new key for slug %s of %s\n",
+ slug,
+ instance_id);
+ iqs = TALER_MERCHANTDB_insert_token_family_key (TMH_db,
+ instance_id,
+ slug,
+ &key.pub,
+ &token_priv,
+ key_expires,
+ key.valid_after,
+ key.valid_before);
+ switch (iqs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ GNUNET_break (0);
+ GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key);
+ GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ /* Report the conflict without advertising an uncommitted key. */
+ GNUNET_break (0);
+ GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key);
+ GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_break (0);
+ GNUNET_CRYPTO_blind_sign_priv_decref (token_priv.private_key);
+ GNUNET_CRYPTO_blind_sign_pub_decref (key.pub.public_key);
+ TMH_token_key_details_free (key_details);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ NULL))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ key_details->pub = key.pub;
+ key_details->priv = token_priv;
+ key_details->signature_validity_start = key.valid_after;
+ key_details->signature_validity_end = key.valid_before;
+ key_details->private_key_deleted_at = key_expires;
+ return GNUNET_OK;
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_token_key_extend (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until)
+{
+ enum GNUNET_DB_QueryStatus qs;
+
+ /* We are about to promise a token of this family, so the private key
+ covering @a valid_at must survive until we sign at @a sign_until. If
+ an existing key covers the validity period but was minted for an order
+ with an earlier pay deadline, extend its lifetime instead of minting a
+ second key for the very same validity period: the key lookup below
+ would otherwise consider that key missing and we would end up with two
+ keys for one validity period. */
+ qs = TALER_MERCHANTDB_update_token_family_key_expiration (
+ TMH_db,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "update_token_family_key_expiration"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ /* Report the conflict without advertising an uncommitted key. */
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ "update_token_family_key_expiration"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ default:
+ /* No key needed extending, or one/more were extended; either is fine. */
+ break;
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Implementation of #TMH_token_key_ensure(). With @a require_coverage,
+ * success may leave the key unset when rounding would fail to cover the
+ * requested time. This allows fountains to stop without storing unusable
+ * keys; the caller must still free the returned family details. The caller
+ * must hold the token family lock throughout this operation.
+ */
+static enum GNUNET_GenericReturnValue
+ensure_key_locked (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until,
+ bool require_coverage,
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details,
+ bool *minted)
+{
+ enum GNUNET_DB_QueryStatus qs;
+ enum GNUNET_GenericReturnValue res;
+
+ *minted = false;
+ res = TMH_token_key_extend (connection,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until);
+ if (GNUNET_OK != res)
+ return res;
+ qs = TALER_MERCHANTDB_get_token_family_key (
+ TMH_db,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until,
+ key_details);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_token_family_key"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ /* Report the conflict without advertising an uncommitted key. */
+ GNUNET_break (0);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ "get_token_family_key"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Token family slug %s unknown at %llu for %llu for instance %s\n",
+ slug,
+ (unsigned long long) valid_at.abs_time.abs_value_us,
+ (unsigned long long) sign_until.abs_time.abs_value_us,
+ instance_id);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN,
+ slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Lookup of token family %s at %llu yielded %s\n",
+ slug,
+ (unsigned long long) valid_at.abs_time.abs_value_us,
+ NULL == key_details->pub.public_key ? "no key" : "a key");
+
+ if (NULL != key_details->pub.public_key)
+ return GNUNET_OK;
+
+ /* No suitable key exists, create one! */
+ {
+ res = mint_key (connection,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until,
+ require_coverage,
+ key_details);
+ if (GNUNET_OK != res)
+ return res;
+ *minted = (NULL != key_details->pub.public_key);
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Hold the family lock across the entire lookup-and-mint operation. Only
+ * commit transactions we started; withdrawal keeps the lock until its quota
+ * and signatures are committed together.
+ */
+static enum GNUNET_GenericReturnValue
+ensure_key (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until,
+ bool require_coverage,
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details,
+ bool *minted)
+{
+ bool started_transaction;
+ enum GNUNET_DB_QueryStatus qs;
+ enum GNUNET_GenericReturnValue res;
+
+ *minted = false;
+ qs = TALER_MERCHANTDB_lock_token_family (TMH_db,
+ slug,
+ &started_transaction);
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ if (started_transaction)
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN,
+ slug))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ if (qs < 0)
+ {
+ if (started_transaction)
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ GNUNET_DB_STATUS_SOFT_ERROR == qs
+ ? TALER_EC_GENERIC_DB_SOFT_FAILURE
+ : TALER_EC_GENERIC_DB_STORE_FAILED,
+ "lock_token_family"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+ }
+ res = ensure_key_locked (connection,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until,
+ require_coverage,
+ key_details,
+ minted);
+ if (! started_transaction)
+ return res;
+ if (GNUNET_OK != res)
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return res;
+ }
+ qs = TALER_MERCHANTDB_commit (TMH_db);
+ if (qs >= 0)
+ return GNUNET_OK;
+ TMH_token_key_details_free (key_details);
+ *minted = false;
+ return (MHD_YES ==
+ TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ GNUNET_DB_STATUS_SOFT_ERROR == qs
+ ? TALER_EC_GENERIC_DB_SOFT_FAILURE
+ : TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ "ensure token family key"))
+ ? GNUNET_NO
+ : GNUNET_SYSERR;
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_token_key_ensure (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until,
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details,
+ bool *minted)
+{
+ return ensure_key (connection,
+ instance_id,
+ slug,
+ valid_at,
+ sign_until,
+ false,
+ key_details,
+ minted);
+}
+
+
+void
+TMH_token_key_window_free (struct TMH_TokenKeyWindow *window)
+{
+ for (unsigned int i = 0; i < window->keys_len; i++)
+ TMH_token_key_details_free (&window->keys[i]);
+ GNUNET_array_grow (window->keys,
+ window->keys_len,
+ 0);
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_token_key_window_get (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const struct TALER_MERCHANTDB_TokenFamilyDetails *tf,
+ struct GNUNET_TIME_Timestamp now,
+ unsigned int key_window_size,
+ struct TMH_TokenKeyWindow *window)
+{
+ struct GNUNET_TIME_Timestamp cursor;
+ struct GNUNET_TIME_Timestamp latest_start;
+
+ *window = (struct TMH_TokenKeyWindow) {0};
+ GNUNET_assert (key_window_size <= TMH_MAX_FOUNTAIN_KEY_WINDOW);
+ latest_start = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (
+ now.abs_time,
+ GNUNET_TIME_relative_multiply (tf->duration,
+ key_window_size)));
+ cursor = GNUNET_TIME_timestamp_max (now,
+ tf->valid_after);
+ /* A future family must fit the horizon measured from now; its start
+ must never move that horizon forward. */
+ if (GNUNET_TIME_timestamp_cmp (cursor, >, latest_start))
+ return GNUNET_OK;
+ for (unsigned int slot = 0; slot <= key_window_size; slot++)
+ {
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd;
+ struct GNUNET_TIME_Timestamp next;
+ enum GNUNET_GenericReturnValue res;
+ bool minted;
+
+ if (GNUNET_TIME_timestamp_cmp (cursor, >=, tf->valid_before))
+ break;
+ res = ensure_key (connection,
+ instance_id,
+ tf->slug,
+ cursor,
+ now,
+ true,
+ &kd,
+ &minted);
+ if (GNUNET_OK != res)
+ {
+ TMH_token_key_window_free (window);
+ return res;
+ }
+ if ( (NULL == kd.pub.public_key) ||
+ GNUNET_TIME_timestamp_cmp (kd.signature_validity_start, >, latest_start) ||
+ GNUNET_TIME_timestamp_cmp (kd.signature_validity_start, >, cursor) ||
+ GNUNET_TIME_timestamp_cmp (kd.signature_validity_end, <, cursor) ||
+ GNUNET_TIME_timestamp_cmp (kd.signature_validity_end,
+ >,
+ tf->valid_before) )
+ {
+ /* Rounding/offsets may not yield further coverage. Do not count
+ a repeated or unusable period as another future key. */
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Token family %s cannot extend fountain coverage at %llu\n",
+ tf->slug,
+ (unsigned long long) cursor.abs_time.abs_value_us);
+ TMH_token_key_details_free (&kd);
+ break;
+ }
+ GNUNET_array_append (window->keys,
+ window->keys_len,
+ kd);
+ /* Lookup includes the expiry instant. Move one whole timestamp
+ tick beyond it, so the previous key cannot be selected again. */
+ next = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (kd.signature_validity_end.abs_time,
+ GNUNET_TIME_UNIT_SECONDS));
+ next = GNUNET_TIME_timestamp_min (next,
+ latest_start);
+ if (GNUNET_TIME_timestamp_cmp (next, <=, kd.signature_validity_end))
+ break; /* reached the horizon, forever or saturated arithmetic */
+ cursor = next;
+ }
+ return GNUNET_OK;
+}
+
+
+enum GNUNET_GenericReturnValue
+TMH_token_key_window_find (
+ const struct TMH_TokenKeyWindow *window,
+ struct GNUNET_TIME_Timestamp valid_at,
+ unsigned int *key_index)
+{
+ /* Wallets can select an advertised key unambiguously by its start,
+ even if an earlier key also covers that instant. */
+ for (unsigned int i = 0; i < window->keys_len; i++)
+ if (GNUNET_TIME_timestamp_cmp (valid_at,
+ ==,
+ window->keys[i].signature_validity_start))
+ {
+ *key_index = i;
+ return GNUNET_OK;
+ }
+ /* Otherwise keep the covering-key convention: first matching key. */
+ for (unsigned int i = 0; i < window->keys_len; i++)
+ if (GNUNET_TIME_timestamp_cmp (valid_at,
+ >=,
+ window->keys[i].signature_validity_start) &&
+ GNUNET_TIME_timestamp_cmp (valid_at,
+ <=,
+ window->keys[i].signature_validity_end))
+ {
+ *key_index = i;
+ return GNUNET_OK;
+ }
+ return GNUNET_NO;
+}
+
+
+void
+TMH_token_family_to_contract (
+ const struct TALER_MERCHANTDB_TokenFamilyDetails *tf,
+ struct TALER_MERCHANT_ContractTokenFamily *family)
+{
+ struct TALER_MERCHANT_ContractTokenFamily new_family = {
+ .slug = GNUNET_strdup (tf->slug),
+ .name = GNUNET_strdup (tf->name),
+ .description = GNUNET_strdup (tf->description),
+ .description_i18n = json_incref (tf->description_i18n),
+ };
+
+ switch (tf->kind)
+ {
+ case TALER_MERCHANTDB_TFK_Subscription:
+ {
+ json_t *tdomains = json_object_get (tf->extra_data,
+ "trusted_domains");
+ json_t *dom;
+ size_t i;
+
+ new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_SUBSCRIPTION;
+ new_family.critical = true;
+ new_family.details.subscription.trusted_domains_len
+ = json_array_size (tdomains);
+ GNUNET_assert (new_family.details.subscription.trusted_domains_len
+ < UINT_MAX);
+ new_family.details.subscription.trusted_domains
+ = GNUNET_new_array (
+ new_family.details.subscription.trusted_domains_len,
+ char *);
+ json_array_foreach (tdomains, i, dom)
+ {
+ const char *val;
+
+ val = json_string_value (dom);
+ GNUNET_break (NULL != val);
+ if (NULL != val)
+ new_family.details.subscription.trusted_domains[i]
+ = GNUNET_strdup (val);
+ }
+ break;
+ }
+ case TALER_MERCHANTDB_TFK_Discount:
+ {
+ json_t *edomains = json_object_get (tf->extra_data,
+ "expected_domains");
+ json_t *dom;
+ size_t i;
+
+ new_family.kind = TALER_MERCHANT_CONTRACT_TOKEN_KIND_DISCOUNT;
+ new_family.critical = false;
+ new_family.details.discount.expected_domains_len
+ = json_array_size (edomains);
+ GNUNET_assert (new_family.details.discount.expected_domains_len
+ < UINT_MAX);
+ new_family.details.discount.expected_domains
+ = GNUNET_new_array (
+ new_family.details.discount.expected_domains_len,
+ char *);
+ json_array_foreach (edomains, i, dom)
+ {
+ const char *val;
+
+ val = json_string_value (dom);
+ GNUNET_break (NULL != val);
+ if (NULL != val)
+ new_family.details.discount.expected_domains[i]
+ = GNUNET_strdup (val);
+ }
+ break;
+ }
+ }
+ *family = new_family;
+}
+
+
+/* end of taler-merchant-httpd_token-keys.c */
diff --git a/src/backend/taler-merchant-httpd_token-keys.h b/src/backend/taler-merchant-httpd_token-keys.h
@@ -0,0 +1,198 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/backend/taler-merchant-httpd_token-keys.h
+ * @brief shared token family key lookup, lifetime extension and creation
+ * for orders and fountains; also provides fountain key windows
+ * @author Christian Blättler
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_TOKEN_KEYS_H
+#define TALER_MERCHANT_HTTPD_TOKEN_KEYS_H
+
+#include "taler-merchant-httpd.h"
+#include "taler/taler_merchant_util.h"
+#include "merchantdb_lib.h"
+#include "merchant-database/get_token_family_key.h"
+
+/**
+ * Hard cap on the ``key_window_size`` of a fountain grant. Every
+ * advertised future slot may require minting (and storing) a fresh
+ * blind-signing key pair on ``GET /fountain/info``, so an unbounded
+ * window would be a denial-of-service vector.
+ */
+#define TMH_MAX_FOUNTAIN_KEY_WINDOW 12
+
+/**
+ * Hard cap on the total number of token envelopes in one
+ * ``POST /fountain/withdraw`` request. Matches the cap on token
+ * outputs of the pay endpoint.
+ */
+#define TMH_MAX_FOUNTAIN_ENVELOPES 64
+
+
+/**
+ * Extend the lifetime of existing private keys covering @a valid_at
+ * until at least @a sign_until. Does not create a key. Order creation
+ * also calls this when reusing a key already added to the contract.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance owning the token family
+ * @param slug slug of the token family
+ * @param valid_at time at which the tokens must be valid
+ * @param sign_until how long the private key must remain usable
+ * @return #GNUNET_OK on success, #GNUNET_NO if an error response was
+ * queued, #GNUNET_SYSERR on hard failure
+ */
+enum GNUNET_GenericReturnValue
+TMH_token_key_extend (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until);
+
+
+/**
+ * Ensure that an issue key of token family @a slug covering @a valid_at
+ * exists and that its private key remains usable for signing until at
+ * least @a sign_until. If a key covering @a valid_at exists but its
+ * private key would be deleted earlier, the private key's lifetime is
+ * extended; only if no key covers @a valid_at at all is a new key pair
+ * generated and stored in the database.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance owning the token family
+ * @param slug slug of the token family
+ * @param valid_at time at which tokens signed with the key must be valid;
+ * determines the key's validity period
+ * @param sign_until how long the private key must remain usable
+ * @param[out] key_details set to the key (public and private part), its
+ * validity bounds and the token family details; on success the
+ * caller must release it via #TMH_token_key_details_free()
+ * @param[out] minted set to true if a fresh key pair was created
+ * @return #GNUNET_OK on success (and only then is @a key_details set),
+ * #GNUNET_NO if an error response was already queued on
+ * @a connection, #GNUNET_SYSERR on hard failure
+ */
+enum GNUNET_GenericReturnValue
+TMH_token_key_ensure (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const char *slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ struct GNUNET_TIME_Timestamp sign_until,
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details,
+ bool *minted);
+
+
+/**
+ * Release all resources of @a key_details (but not the structure
+ * itself, which is typically stack-allocated).
+ *
+ * @param[in,out] key_details result of a successful
+ * #TMH_token_key_ensure() to release
+ */
+void
+TMH_token_key_details_free (
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *key_details);
+
+
+/**
+ * Current and future keys of a fountain grant, in order of strictly
+ * increasing expiry. Owns the key and family details.
+ */
+struct TMH_TokenKeyWindow
+{
+ /** Keys selected using the shared order lookup and generation logic. */
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails *keys;
+
+ /** Number of keys; at most key_window_size + 1. */
+ unsigned int keys_len;
+};
+
+
+/**
+ * Obtain the current key and up to @a key_window_size future keys.
+ * The first lookup is at max(now, family.valid_after); each subsequent
+ * lookup is one second beyond the previous key's actual expiry, capped
+ * at now + key_window_size * duration. No key may start after that
+ * horizon, including when the family itself starts in the future.
+ * Rounding can make the validity intervals overlap. Stop at the family
+ * expiry or when the existing validity rules cannot extend coverage.
+ *
+ * @param connection connection to report errors on
+ * @param instance_id instance owning the token family
+ * @param tf token family details
+ * @param now time of this request, also the private-key retention minimum
+ * @param key_window_size number of future keys, at most
+ * #TMH_MAX_FOUNTAIN_KEY_WINDOW (zero allows the current key only)
+ * @param[out] window initialized window; empty on error
+ * @return #GNUNET_OK on success (possibly an empty/shorter window),
+ * #GNUNET_NO if an error response was queued,
+ * #GNUNET_SYSERR on hard failure
+ */
+enum GNUNET_GenericReturnValue
+TMH_token_key_window_get (
+ struct MHD_Connection *connection,
+ const char *instance_id,
+ const struct TALER_MERCHANTDB_TokenFamilyDetails *tf,
+ struct GNUNET_TIME_Timestamp now,
+ unsigned int key_window_size,
+ struct TMH_TokenKeyWindow *window);
+
+
+/**
+ * Release the window's keys and family details and reset it to empty.
+ *
+ * @param[in,out] window window to release
+ */
+void
+TMH_token_key_window_free (struct TMH_TokenKeyWindow *window);
+
+
+/**
+ * Select a key from an advertised window. An exact validity-start match
+ * takes precedence over overlapping earlier keys. Otherwise the first
+ * key covering @a valid_at is selected. A missing valid_at in the wallet
+ * request selects index zero directly, rather than calling this function.
+ *
+ * @param window current window of the grant
+ * @param valid_at requested validity time
+ * @param[out] key_index selected index
+ * @return #GNUNET_OK on match, #GNUNET_NO if outside the window
+ */
+enum GNUNET_GenericReturnValue
+TMH_token_key_window_find (
+ const struct TMH_TokenKeyWindow *window,
+ struct GNUNET_TIME_Timestamp valid_at,
+ unsigned int *key_index);
+
+
+/**
+ * Convert token family details from the database into a contract
+ * token family (without keys). The result must be released via
+ * #TALER_MERCHANT_contract_token_family_free().
+ *
+ * @param tf token family details from the database
+ * @param[out] family initialized contract token family
+ */
+void
+TMH_token_family_to_contract (
+ const struct TALER_MERCHANTDB_TokenFamilyDetails *tf,
+ struct TALER_MERCHANT_ContractTokenFamily *family);
+
+#endif
diff --git a/src/backenddb/delete_fountain.c b/src/backenddb/delete_fountain.c
@@ -0,0 +1,47 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/delete_fountain.c
+ * @brief Implementation of the delete_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/delete_fountain.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_delete_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (fountain_id),
+ GNUNET_PQ_query_param_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "DELETE FROM merchant_fountains"
+ " WHERE fountain_id=$1");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "",
+ params);
+}
diff --git a/src/backenddb/do_fountain_withdraw.c b/src/backenddb/do_fountain_withdraw.c
@@ -0,0 +1,127 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/do_fountain_withdraw.c
+ * @brief Implementation of the do_fountain_withdraw function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/do_fountain_withdraw.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_fountain_withdraw (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ uint64_t fountain_serial,
+ unsigned int num_entries,
+ const uint64_t token_family_serials[static num_entries],
+ const struct GNUNET_TIME_Timestamp slot_starts[static num_entries],
+ const uint64_t num_requested[static num_entries],
+ unsigned int *failed_index,
+ bool *no_grant,
+ bool *exceeded)
+{
+ unsigned int order[GNUNET_NZL (num_entries)];
+ uint64_t s_families[GNUNET_NZL (num_entries)];
+ uint64_t s_slots[GNUNET_NZL (num_entries)];
+ uint64_t s_counts[GNUNET_NZL (num_entries)];
+ uint32_t sql_failed_index;
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&fountain_serial),
+ GNUNET_PQ_query_param_array_uint64 (num_entries,
+ s_families,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (num_entries,
+ s_slots,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (num_entries,
+ s_counts,
+ pg->conn),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint32 ("out_failed_index",
+ &sql_failed_index),
+ GNUNET_PQ_result_spec_bool ("out_no_grant",
+ no_grant),
+ GNUNET_PQ_result_spec_bool ("out_exceeded",
+ exceeded),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ *failed_index = num_entries;
+ *no_grant = false;
+ *exceeded = false;
+ /* Sort entries by (token_family_serial, slot_start) so that
+ concurrent withdrawals acquire the counter row locks in a
+ deterministic order and cannot deadlock. Insertion sort: the
+ number of entries is bounded by the request size cap. */
+ for (unsigned int i = 0; i < num_entries; i++)
+ order[i] = i;
+ for (unsigned int i = 1; i < num_entries; i++)
+ {
+ unsigned int cur = order[i];
+ unsigned int j = i;
+
+ while ( (j > 0) &&
+ ( (token_family_serials[order[j - 1]] >
+ token_family_serials[cur]) ||
+ ( (token_family_serials[order[j - 1]] ==
+ token_family_serials[cur]) &&
+ (slot_starts[order[j - 1]].abs_time.abs_value_us >
+ slot_starts[cur].abs_time.abs_value_us) ) ) )
+ {
+ order[j] = order[j - 1];
+ j--;
+ }
+ order[j] = cur;
+ }
+ for (unsigned int i = 0; i < num_entries; i++)
+ {
+ s_families[i] = token_family_serials[order[i]];
+ s_slots[i] = slot_starts[order[i]].abs_time.abs_value_us;
+ s_counts[i] = num_requested[order[i]];
+ }
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " out_failed_index"
+ " ,out_no_grant"
+ " ,out_exceeded"
+ " FROM merchant_do_fountain_withdraw"
+ " ($1, $2, $3, $4);");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+ GNUNET_PQ_cleanup_query_params_closures (params);
+ if (qs < 0)
+ return qs;
+ if (0 != sql_failed_index)
+ {
+ /* Map the 1-based index into the sorted arrays back to the
+ caller's entry order. */
+ GNUNET_assert (sql_failed_index <= num_entries);
+ *failed_index = order[sql_failed_index - 1];
+ }
+ return qs;
+}
diff --git a/src/backenddb/do_fountain_withdraw.sql b/src/backenddb/do_fountain_withdraw.sql
@@ -0,0 +1,101 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+--
+
+DROP FUNCTION IF EXISTS merchant_do_fountain_withdraw;
+CREATE FUNCTION merchant_do_fountain_withdraw(
+ IN in_fountain_serial INT8
+ ,IN in_token_family_serials INT8[]
+ ,IN in_slot_starts INT8[]
+ ,IN in_num_requested INT8[]
+ ,OUT out_failed_index INT4
+ ,OUT out_no_grant BOOL
+ ,OUT out_exceeded BOOL)
+LANGUAGE plpgsql
+AS $$
+-- Atomically checks and consumes per-period withdrawal quota for a
+-- fountain. The parallel input arrays hold one entry per requested
+-- (token family, key slot) pair; entries must be distinct and sorted
+-- by (token_family_serial, slot_start) by the caller so that
+-- concurrent requests acquire row locks in a deterministic order.
+--
+-- All-or-nothing: if any entry has no matching grant or would exceed
+-- its tokens_per_period_limit, no counter is modified and
+-- out_failed_index reports the offending (1-based) entry.
+DECLARE
+ my_limit INT8;
+ my_count INT8;
+ i INT4;
+BEGIN
+ out_failed_index = 0;
+ out_no_grant = FALSE;
+ out_exceeded = FALSE;
+
+ -- Pass 1: lock counters and check limits without modifying them.
+ FOR i IN 1..COALESCE(array_length (in_token_family_serials, 1), 0)
+ LOOP
+ SELECT tokens_per_period_limit
+ INTO my_limit
+ FROM merchant_fountain_grants
+ WHERE fountain_serial = in_fountain_serial
+ AND token_family_serial = in_token_family_serials[i];
+ IF NOT FOUND
+ THEN
+ out_no_grant = TRUE;
+ out_failed_index = i;
+ RETURN;
+ END IF;
+
+ -- Ensure the counter row exists (a zero-count row is semantically
+ -- inert, so leaving it behind on failure is harmless), then lock it.
+ INSERT INTO merchant_fountain_withdrawals
+ (fountain_serial
+ ,token_family_serial
+ ,slot_start
+ ,num_withdrawn
+ ) VALUES
+ (in_fountain_serial
+ ,in_token_family_serials[i]
+ ,in_slot_starts[i]
+ ,0)
+ ON CONFLICT DO NOTHING;
+
+ SELECT num_withdrawn
+ INTO my_count
+ FROM merchant_fountain_withdrawals
+ WHERE fountain_serial = in_fountain_serial
+ AND token_family_serial = in_token_family_serials[i]
+ AND slot_start = in_slot_starts[i]
+ FOR UPDATE;
+
+ IF my_count + in_num_requested[i] > my_limit
+ THEN
+ out_exceeded = TRUE;
+ out_failed_index = i;
+ RETURN;
+ END IF;
+ END LOOP;
+
+ -- Pass 2: all entries fit, consume the quota.
+ FOR i IN 1..COALESCE(array_length (in_token_family_serials, 1), 0)
+ LOOP
+ UPDATE merchant_fountain_withdrawals
+ SET num_withdrawn = num_withdrawn + in_num_requested[i]
+ WHERE fountain_serial = in_fountain_serial
+ AND token_family_serial = in_token_family_serials[i]
+ AND slot_start = in_slot_starts[i];
+ END LOOP;
+
+END $$;
diff --git a/src/backenddb/do_insert_fountain.c b/src/backenddb/do_insert_fountain.c
@@ -0,0 +1,98 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/do_insert_fountain.c
+ * @brief Implementation of the do_insert_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/do_insert_fountain.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_insert_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ const struct GNUNET_HashCode *h_fountain_secret,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int grants_len,
+ const struct TALER_MERCHANTDB_FountainGrant grants[static grants_len],
+ char **unknown_slug,
+ bool *conflict)
+{
+ const char *slugs[GNUNET_NZL (grants_len)];
+ uint64_t limits[GNUNET_NZL (grants_len)];
+ uint64_t stashes[GNUNET_NZL (grants_len)];
+ uint64_t windows[GNUNET_NZL (grants_len)];
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (fountain_id),
+ GNUNET_PQ_query_param_auto_from_type (h_fountain_secret),
+ GNUNET_PQ_query_param_string (description),
+ GNUNET_PQ_query_param_relative_time (&poll_freq),
+ GNUNET_PQ_query_param_array_ptrs_string (grants_len,
+ slugs,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ limits,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ stashes,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ windows,
+ pg->conn),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_string ("out_unknown_slug",
+ unknown_slug),
+ NULL),
+ GNUNET_PQ_result_spec_bool ("out_conflict",
+ conflict),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ *unknown_slug = NULL;
+ *conflict = false;
+ for (unsigned int i = 0; i < grants_len; i++)
+ {
+ slugs[i] = grants[i].token_family_slug;
+ limits[i] = grants[i].tokens_per_period_limit;
+ stashes[i] = grants[i].tokens_per_period_stash;
+ windows[i] = grants[i].key_window_size;
+ }
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " out_unknown_slug"
+ " ,out_conflict"
+ " FROM merchant_do_insert_fountain"
+ " ($1, $2, $3, $4, $5, $6, $7, $8);");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+ GNUNET_PQ_cleanup_query_params_closures (params);
+ return qs;
+}
diff --git a/src/backenddb/do_insert_fountain.sql b/src/backenddb/do_insert_fountain.sql
@@ -0,0 +1,92 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+--
+
+DROP FUNCTION IF EXISTS merchant_do_insert_fountain;
+CREATE FUNCTION merchant_do_insert_fountain(
+ IN in_fountain_id TEXT
+ ,IN in_h_fountain_secret BYTEA
+ ,IN in_description TEXT
+ ,IN in_poll_freq INT8
+ ,IN in_token_family_slugs TEXT[]
+ ,IN in_tokens_per_period_limits INT8[]
+ ,IN in_tokens_per_period_stashes INT8[]
+ ,IN in_key_window_sizes INT8[]
+ ,OUT out_unknown_slug TEXT
+ ,OUT out_conflict BOOL)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ my_tf_serials INT8[];
+ my_tf_serial INT8;
+ my_fountain_serial INT8;
+ i INT4;
+BEGIN
+ out_unknown_slug = NULL;
+ out_conflict = FALSE;
+
+ -- Resolve all token family slugs before creating anything, so an
+ -- unknown slug leaves no trace.
+ my_tf_serials = ARRAY[]::INT8[];
+ FOR i IN 1..COALESCE(array_length (in_token_family_slugs, 1), 0)
+ LOOP
+ SELECT token_family_serial
+ INTO my_tf_serial
+ FROM merchant_token_families
+ WHERE slug = in_token_family_slugs[i];
+ IF NOT FOUND
+ THEN
+ out_unknown_slug = in_token_family_slugs[i];
+ RETURN;
+ END IF;
+ my_tf_serials = array_append (my_tf_serials, my_tf_serial);
+ END LOOP;
+
+ INSERT INTO merchant_fountains
+ (fountain_id
+ ,h_fountain_secret
+ ,description
+ ,poll_freq
+ ) VALUES
+ (in_fountain_id
+ ,in_h_fountain_secret
+ ,in_description
+ ,in_poll_freq)
+ ON CONFLICT DO NOTHING
+ RETURNING fountain_serial
+ INTO my_fountain_serial;
+ IF NOT FOUND
+ THEN
+ out_conflict = TRUE;
+ RETURN;
+ END IF;
+
+ FOR i IN 1..COALESCE(array_length (my_tf_serials, 1), 0)
+ LOOP
+ INSERT INTO merchant_fountain_grants
+ (fountain_serial
+ ,token_family_serial
+ ,tokens_per_period_limit
+ ,tokens_per_period_stash
+ ,key_window_size
+ ) VALUES
+ (my_fountain_serial
+ ,my_tf_serials[i]
+ ,in_tokens_per_period_limits[i]
+ ,in_tokens_per_period_stashes[i]
+ ,in_key_window_sizes[i]);
+ END LOOP;
+
+END $$;
diff --git a/src/backenddb/do_update_fountain.c b/src/backenddb/do_update_fountain.c
@@ -0,0 +1,102 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/do_update_fountain.c
+ * @brief Implementation of the do_update_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/do_update_fountain.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_update_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ const char *description,
+ const struct GNUNET_TIME_Relative *poll_freq,
+ bool replace_grants,
+ unsigned int grants_len,
+ const struct TALER_MERCHANTDB_FountainGrant *grants,
+ bool *not_found,
+ char **unknown_slug)
+{
+ const char *slugs[GNUNET_NZL (grants_len)];
+ uint64_t limits[GNUNET_NZL (grants_len)];
+ uint64_t stashes[GNUNET_NZL (grants_len)];
+ uint64_t windows[GNUNET_NZL (grants_len)];
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (fountain_id),
+ (NULL == description)
+ ? GNUNET_PQ_query_param_null ()
+ : GNUNET_PQ_query_param_string (description),
+ (NULL == poll_freq)
+ ? GNUNET_PQ_query_param_null ()
+ : GNUNET_PQ_query_param_relative_time (poll_freq),
+ GNUNET_PQ_query_param_bool (replace_grants),
+ GNUNET_PQ_query_param_array_ptrs_string (grants_len,
+ slugs,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ limits,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ stashes,
+ pg->conn),
+ GNUNET_PQ_query_param_array_uint64 (grants_len,
+ windows,
+ pg->conn),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_bool ("out_not_found",
+ not_found),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_string ("out_unknown_slug",
+ unknown_slug),
+ NULL),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ *not_found = false;
+ *unknown_slug = NULL;
+ for (unsigned int i = 0; i < grants_len; i++)
+ {
+ slugs[i] = grants[i].token_family_slug;
+ limits[i] = grants[i].tokens_per_period_limit;
+ stashes[i] = grants[i].tokens_per_period_stash;
+ windows[i] = grants[i].key_window_size;
+ }
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " out_not_found"
+ " ,out_unknown_slug"
+ " FROM merchant_do_update_fountain"
+ " ($1, $2, $3, $4, $5, $6, $7, $8);");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+ GNUNET_PQ_cleanup_query_params_closures (params);
+ return qs;
+}
diff --git a/src/backenddb/do_update_fountain.sql b/src/backenddb/do_update_fountain.sql
@@ -0,0 +1,100 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+--
+
+DROP FUNCTION IF EXISTS merchant_do_update_fountain;
+CREATE FUNCTION merchant_do_update_fountain(
+ IN in_fountain_id TEXT
+ ,IN in_description TEXT -- can be NULL: keep previous value
+ ,IN in_poll_freq INT8 -- can be NULL: keep previous value
+ ,IN in_replace_grants BOOL
+ ,IN in_token_family_slugs TEXT[]
+ ,IN in_tokens_per_period_limits INT8[]
+ ,IN in_tokens_per_period_stashes INT8[]
+ ,IN in_key_window_sizes INT8[]
+ ,OUT out_not_found BOOL
+ ,OUT out_unknown_slug TEXT)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ my_tf_serials INT8[];
+ my_tf_serial INT8;
+ my_fountain_serial INT8;
+ i INT4;
+BEGIN
+ out_not_found = FALSE;
+ out_unknown_slug = NULL;
+
+ SELECT fountain_serial
+ INTO my_fountain_serial
+ FROM merchant_fountains
+ WHERE fountain_id = in_fountain_id;
+ IF NOT FOUND
+ THEN
+ out_not_found = TRUE;
+ RETURN;
+ END IF;
+
+ -- Resolve all token family slugs before modifying anything, so an
+ -- unknown slug leaves the fountain unchanged.
+ my_tf_serials = ARRAY[]::INT8[];
+ IF in_replace_grants
+ THEN
+ FOR i IN 1..COALESCE(array_length (in_token_family_slugs, 1), 0)
+ LOOP
+ SELECT token_family_serial
+ INTO my_tf_serial
+ FROM merchant_token_families
+ WHERE slug = in_token_family_slugs[i];
+ IF NOT FOUND
+ THEN
+ out_unknown_slug = in_token_family_slugs[i];
+ RETURN;
+ END IF;
+ my_tf_serials = array_append (my_tf_serials, my_tf_serial);
+ END LOOP;
+ END IF;
+
+ UPDATE merchant_fountains
+ SET description = COALESCE(in_description, description)
+ ,poll_freq = COALESCE(in_poll_freq, poll_freq)
+ WHERE fountain_serial = my_fountain_serial;
+
+ IF in_replace_grants
+ THEN
+ -- Replace the grant set. Withdrawal counters in
+ -- merchant_fountain_withdrawals are deliberately left untouched:
+ -- consumed quota must survive a grant replacement.
+ DELETE FROM merchant_fountain_grants
+ WHERE fountain_serial = my_fountain_serial;
+
+ FOR i IN 1..COALESCE(array_length (my_tf_serials, 1), 0)
+ LOOP
+ INSERT INTO merchant_fountain_grants
+ (fountain_serial
+ ,token_family_serial
+ ,tokens_per_period_limit
+ ,tokens_per_period_stash
+ ,key_window_size
+ ) VALUES
+ (my_fountain_serial
+ ,my_tf_serials[i]
+ ,in_tokens_per_period_limits[i]
+ ,in_tokens_per_period_stashes[i]
+ ,in_key_window_sizes[i]);
+ END LOOP;
+ END IF;
+
+END $$;
diff --git a/src/backenddb/gc.sql b/src/backenddb/gc.sql
@@ -34,6 +34,24 @@ BEGIN
EXECUTE format('CALL %I.merchant_statistic_counter_gc()', s);
EXECUTE format('DELETE FROM %I.merchant_unclaim_signatures'
' WHERE expiration_time < $1', s) USING in_now;
+ -- Compare elapsed time instead of adding the duration to the start:
+ -- indefinite durations use INT8_MAX and must never overflow here.
+ EXECUTE format('DELETE FROM %I.merchant_fountain_withdrawals fw'
+ ' USING %I.merchant_token_families tf'
+ ' WHERE tf.token_family_serial = fw.token_family_serial'
+ ' AND tf.duration < $1 - fw.slot_start', s, s)
+ USING in_now;
+ -- Keep the complete response until the last issue key of that
+ -- withdrawal expires. Collecting individual signatures would leave
+ -- an incomplete response for requests spanning multiple key periods.
+ EXECUTE format('DELETE FROM %I.merchant_fountain_withdraw_sigs fws'
+ ' USING (SELECT saved.fountain_serial, saved.h_request'
+ ' FROM %I.merchant_fountain_withdraw_sigs saved'
+ ' GROUP BY saved.fountain_serial, saved.h_request'
+ ' HAVING max(saved.signature_validity_end) < $1) expired'
+ ' WHERE fws.fountain_serial = expired.fountain_serial'
+ ' AND fws.h_request = expired.h_request', s, s)
+ USING in_now;
EXCEPTION
WHEN undefined_table THEN
NULL;
diff --git a/src/backenddb/get_fountain.c b/src/backenddb/get_fountain.c
@@ -0,0 +1,62 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/get_fountain.c
+ * @brief Implementation of the get_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/get_fountain.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ struct TALER_MERCHANTDB_FountainDetails *fd)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (fountain_id),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("fountain_serial",
+ &fd->fountain_serial),
+ GNUNET_PQ_result_spec_string ("description",
+ &fd->description),
+ GNUNET_PQ_result_spec_relative_time ("poll_freq",
+ &fd->poll_freq),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " fountain_serial"
+ ",description"
+ ",poll_freq"
+ " FROM merchant_fountains"
+ " WHERE fountain_id=$1");
+ return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+}
diff --git a/src/backenddb/get_fountain_by_secret.c b/src/backenddb/get_fountain_by_secret.c
@@ -0,0 +1,60 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/get_fountain_by_secret.c
+ * @brief Implementation of the get_fountain_by_secret function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/get_fountain_by_secret.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain_by_secret (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const struct GNUNET_HashCode *h_fountain_secret,
+ uint64_t *fountain_serial,
+ struct GNUNET_TIME_Relative *poll_freq)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (h_fountain_secret),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("fountain_serial",
+ fountain_serial),
+ GNUNET_PQ_result_spec_relative_time ("poll_freq",
+ poll_freq),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " fountain_serial"
+ ",poll_freq"
+ " FROM merchant_fountains"
+ " WHERE h_fountain_secret=$1");
+ return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+}
diff --git a/src/backenddb/get_fountain_withdraw.c b/src/backenddb/get_fountain_withdraw.c
@@ -0,0 +1,175 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+/**
+ * @file src/backenddb/get_fountain_withdraw.c
+ * @brief look up the signatures of a completed fountain withdrawal
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/get_fountain_withdraw.h"
+#include "helper.h"
+
+
+/**
+ * Closure for #restore_sig_cb().
+ */
+struct RestoreContext
+{
+ /**
+ * Callback to hand each signature to.
+ */
+ TALER_MERCHANTDB_FountainWithdrawSigCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Set to a hard error if a row could not be extracted.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Extract the stored signatures and pass them on in response order.
+ *
+ * @param cls a `struct RestoreContext *`
+ * @param result the postgres result
+ * @param num_results number of rows in @a result
+ */
+static void
+restore_sig_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct RestoreContext *rc = cls;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ uint32_t grant_index;
+ char *slug;
+ struct TALER_TokenIssuePublicKeyHashP h_issue;
+ struct GNUNET_CRYPTO_BlindedSignature *sig;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint32 ("grant_index",
+ &grant_index),
+ GNUNET_PQ_result_spec_string ("token_family_slug",
+ &slug),
+ GNUNET_PQ_result_spec_auto_from_type ("h_issue",
+ &h_issue),
+ GNUNET_PQ_result_spec_blinded_sig ("token_blinded_signature",
+ &sig),
+ GNUNET_PQ_result_spec_end
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ rc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ rc->cb (rc->cb_cls,
+ grant_index,
+ slug,
+ &h_issue,
+ sig);
+ GNUNET_PQ_cleanup_result (rs);
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain_withdraw (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ uint64_t fountain_serial,
+ const struct GNUNET_HashCode *h_request,
+ bool *not_found,
+ TALER_MERCHANTDB_FountainWithdrawSigCallback cb,
+ void *cb_cls)
+{
+ struct RestoreContext rc = {
+ .cb = cb,
+ .cb_cls = cb_cls,
+ .qs = GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (NULL != pg->transaction_name);
+ *not_found = false;
+ {
+ uint64_t serial;
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&fountain_serial),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("fountain_serial",
+ &serial),
+ GNUNET_PQ_result_spec_end
+ };
+
+ TMH_PQ_prepare_anon (pg,
+ "SELECT fountain_serial"
+ " FROM merchant_fountains"
+ " WHERE fountain_serial = $1"
+ " FOR UPDATE");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+ if (qs <= 0)
+ {
+ *not_found = (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs);
+ return qs;
+ }
+ }
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&fountain_serial),
+ GNUNET_PQ_query_param_auto_from_type (h_request),
+ GNUNET_PQ_query_param_end
+ };
+
+ /* A fresh statement snapshot: this runs after waiting for the lock,
+ so it sees a withdrawal that a competing request just committed. */
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " fws.grant_index"
+ " ,fws.token_blinded_signature"
+ " ,fws.h_issue"
+ " ,fws.token_family_slug"
+ " FROM merchant_fountain_withdraw_sigs AS fws"
+ " WHERE fws.fountain_serial = $1"
+ " AND fws.h_request = $2"
+ " ORDER BY fws.grant_index ASC"
+ " ,fws.token_index ASC");
+ qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
+ "",
+ params,
+ &restore_sig_cb,
+ &rc);
+ /* propagate an extraction failure recorded by the row callback */
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != rc.qs)
+ return rc.qs;
+ return qs;
+ }
+}
diff --git a/src/backenddb/get_token_family_key.c b/src/backenddb/get_token_family_key.c
@@ -26,10 +26,47 @@
#include <taler/taler_dbevents.h>
#include <taler/taler_pq_lib.h>
#include "merchant-database/get_token_family_key.h"
+#include "merchant-database/start.h"
#include "helper.h"
enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_lock_token_family (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *token_family_slug,
+ bool *started_transaction)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (token_family_slug),
+ GNUNET_PQ_query_param_end
+ };
+
+ *started_transaction = false;
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ if (NULL == pg->transaction_name)
+ {
+ if (GNUNET_OK !=
+ TALER_MERCHANTDB_start_read_committed (pg,
+ "ensure token family key"))
+ return GNUNET_DB_STATUS_HARD_ERROR;
+ *started_transaction = true;
+ }
+ /* Touch the parent even when no keys exist. A plain SELECT FOR UPDATE
+ would allow a caller with an older SERIALIZABLE snapshot to miss keys
+ inserted by the previous lock holder. Updating the row forces that
+ caller to fail with a serialization error instead of minting a duplicate.
+ Under READ COMMITTED the subsequent lookup sees the committed key. */
+ TMH_PQ_prepare_anon (pg,
+ "UPDATE merchant_token_families"
+ " SET issued=issued"
+ " WHERE slug=$1");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "",
+ params);
+}
+
+
+enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_get_token_family_key (
struct TALER_MERCHANTDB_PostgresContext *pg,
const char *instance_id,
diff --git a/src/backenddb/insert_fountain_withdraw_sig.c b/src/backenddb/insert_fountain_withdraw_sig.c
@@ -0,0 +1,68 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+/**
+ * @file src/backenddb/insert_fountain_withdraw_sig.c
+ * @brief record one blind signature of a completed fountain withdrawal
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/insert_fountain_withdraw_sig.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_insert_fountain_withdraw_sig (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ uint64_t fountain_serial,
+ const struct GNUNET_HashCode *h_request,
+ uint32_t grant_index,
+ uint32_t token_index,
+ const struct TALER_TokenIssuePublicKeyHashP *h_issue_pub,
+ const struct TALER_BlindedTokenIssueSignature *blind_sig)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&fountain_serial),
+ GNUNET_PQ_query_param_auto_from_type (h_request),
+ GNUNET_PQ_query_param_uint32 (&grant_index),
+ GNUNET_PQ_query_param_uint32 (&token_index),
+ GNUNET_PQ_query_param_auto_from_type (h_issue_pub),
+ GNUNET_PQ_query_param_blinded_sig (blind_sig->signature),
+ GNUNET_PQ_query_param_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (NULL != pg->transaction_name);
+ TMH_PQ_prepare_anon (pg,
+ "INSERT INTO merchant_fountain_withdraw_sigs"
+ " (fountain_serial"
+ " ,h_request"
+ " ,grant_index"
+ " ,token_index"
+ " ,token_family_slug"
+ " ,h_issue"
+ " ,signature_validity_end"
+ " ,token_blinded_signature"
+ ")"
+ " SELECT $1, $2, $3, $4,"
+ " tf.slug, k.h_pub, k.signature_validity_end, $6"
+ " FROM merchant_token_family_keys k"
+ " JOIN merchant_token_families tf"
+ " USING (token_family_serial)"
+ " WHERE k.h_pub = $5");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "",
+ params);
+}
diff --git a/src/backenddb/insert_issued_token.c b/src/backenddb/insert_issued_token.c
@@ -34,7 +34,9 @@ TALER_MERCHANTDB_insert_issued_token (
{
struct GNUNET_PQ_QueryParam params[] = {
GNUNET_PQ_query_param_auto_from_type (h_issue_pub),
- GNUNET_PQ_query_param_auto_from_type (h_contract_terms),
+ (NULL == h_contract_terms)
+ ? GNUNET_PQ_query_param_null ()
+ : GNUNET_PQ_query_param_auto_from_type (h_contract_terms),
GNUNET_PQ_query_param_blinded_sig (blind_sig->signature),
GNUNET_PQ_query_param_end
};
diff --git a/src/backenddb/iterate_fountain_grants.c b/src/backenddb/iterate_fountain_grants.c
@@ -0,0 +1,147 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/iterate_fountain_grants.c
+ * @brief Implementation of the iterate_fountain_grants function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/iterate_fountain_grants.h"
+#include "helper.h"
+
+
+/**
+ * Context used for TALER_MERCHANTDB_iterate_fountain_grants().
+ */
+struct LookupFountainGrantsContext
+{
+ /**
+ * Function to call with the results.
+ */
+ TALER_MERCHANTDB_FountainGrantsCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Did database result extraction fail?
+ */
+ bool extract_failed;
+};
+
+
+/**
+ * Function to be called with the results of a SELECT statement
+ * that has returned @a num_results results about fountain grants.
+ *
+ * @param[in,out] cls of type `struct LookupFountainGrantsContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+lookup_fountain_grants_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct LookupFountainGrantsContext *lgc = cls;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ char *token_family_slug;
+ uint64_t token_family_serial;
+ uint64_t tokens_per_period_limit;
+ uint64_t tokens_per_period_stash;
+ uint64_t key_window_size;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_string ("slug",
+ &token_family_slug),
+ GNUNET_PQ_result_spec_uint64 ("token_family_serial",
+ &token_family_serial),
+ GNUNET_PQ_result_spec_uint64 ("tokens_per_period_limit",
+ &tokens_per_period_limit),
+ GNUNET_PQ_result_spec_uint64 ("tokens_per_period_stash",
+ &tokens_per_period_stash),
+ GNUNET_PQ_result_spec_uint64 ("key_window_size",
+ &key_window_size),
+ GNUNET_PQ_result_spec_end
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ lgc->extract_failed = true;
+ return;
+ }
+ lgc->cb (lgc->cb_cls,
+ token_family_slug,
+ token_family_serial,
+ tokens_per_period_limit,
+ tokens_per_period_stash,
+ (uint32_t) key_window_size);
+ GNUNET_PQ_cleanup_result (rs);
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_fountain_grants (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ uint64_t fountain_serial,
+ TALER_MERCHANTDB_FountainGrantsCallback cb,
+ void *cb_cls)
+{
+ struct LookupFountainGrantsContext lgc = {
+ .cb = cb,
+ .cb_cls = cb_cls,
+ .extract_failed = false,
+ };
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&fountain_serial),
+ GNUNET_PQ_query_param_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " tf.slug"
+ ",fg.token_family_serial"
+ ",fg.tokens_per_period_limit"
+ ",fg.tokens_per_period_stash"
+ ",fg.key_window_size"
+ " FROM merchant_fountain_grants fg"
+ " JOIN merchant_token_families tf"
+ " USING (token_family_serial)"
+ " WHERE fg.fountain_serial=$1"
+ " ORDER BY tf.slug ASC");
+ qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
+ "",
+ params,
+ &lookup_fountain_grants_cb,
+ &lgc);
+ if (lgc.extract_failed)
+ return GNUNET_DB_STATUS_HARD_ERROR;
+ return qs;
+}
diff --git a/src/backenddb/iterate_fountains.c b/src/backenddb/iterate_fountains.c
@@ -0,0 +1,126 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/backenddb/iterate_fountains.c
+ * @brief Implementation of the iterate_fountains function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/iterate_fountains.h"
+#include "helper.h"
+
+
+/**
+ * Context used for TALER_MERCHANTDB_iterate_fountains().
+ */
+struct LookupFountainsContext
+{
+ /**
+ * Function to call with the results.
+ */
+ TALER_MERCHANTDB_FountainsCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Did database result extraction fail?
+ */
+ bool extract_failed;
+};
+
+
+/**
+ * Function to be called with the results of a SELECT statement
+ * that has returned @a num_results results about fountains.
+ *
+ * @param[in,out] cls of type `struct LookupFountainsContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+lookup_fountains_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct LookupFountainsContext *lfc = cls;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ char *fountain_id;
+ char *description;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_string ("fountain_id",
+ &fountain_id),
+ GNUNET_PQ_result_spec_string ("description",
+ &description),
+ GNUNET_PQ_result_spec_end
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ lfc->extract_failed = true;
+ return;
+ }
+ lfc->cb (lfc->cb_cls,
+ fountain_id,
+ description);
+ GNUNET_PQ_cleanup_result (rs);
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_fountains (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ TALER_MERCHANTDB_FountainsCallback cb,
+ void *cb_cls)
+{
+ struct LookupFountainsContext lfc = {
+ .cb = cb,
+ .cb_cls = cb_cls,
+ .extract_failed = false,
+ };
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " fountain_id"
+ ",description"
+ " FROM merchant_fountains");
+ qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
+ "",
+ params,
+ &lookup_fountains_cb,
+ &lfc);
+ if (lfc.extract_failed)
+ return GNUNET_DB_STATUS_HARD_ERROR;
+ return qs;
+}
diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build
@@ -20,6 +20,16 @@ libtalermerchantdb = library(
'insert_kyc_failure.c',
'insert_kyc_status.c',
'do_insert_account.c',
+ 'do_insert_fountain.c',
+ 'do_update_fountain.c',
+ 'do_fountain_withdraw.c',
+ 'delete_fountain.c',
+ 'get_fountain.c',
+ 'get_fountain_withdraw.c',
+ 'insert_fountain_withdraw_sig.c',
+ 'get_fountain_by_secret.c',
+ 'iterate_fountains.c',
+ 'iterate_fountain_grants.c',
'get_exists_transfer.c',
'delete_category.c',
'delete_contract_terms.c',
diff --git a/src/backenddb/sql-schema/merchant-0048.sql b/src/backenddb/sql-schema/merchant-0048.sql
@@ -12,9 +12,10 @@
--
-- You should have received a copy of the GNU General Public License along with
-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+--
-- @file merchant-0048.sql
--- @brief Add challenge-signature POS confirmations (DD 97)
+-- @brief Add DD97 challenge confirmations and DD98 token fountains.
-- @author Bohdan Potuzhnyi
BEGIN;
@@ -50,17 +51,145 @@ BEGIN
COMMENT ON COLUMN merchant_contract_terms.pos_challenge
IS 'Challenge to sign when pos_algorithm is a challenge-signature algorithm, NULL otherwise';
+
+ CREATE TABLE merchant_fountains (
+ fountain_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ fountain_id TEXT NOT NULL UNIQUE,
+ h_fountain_secret BYTEA NOT NULL UNIQUE,
+ description TEXT NOT NULL,
+ poll_freq INT8 NOT NULL,
+ CONSTRAINT merchant_fountains_h_fountain_secret_check
+ CHECK ((LENGTH(h_fountain_secret) = 64))
+ );
+ COMMENT ON TABLE merchant_fountains IS
+ 'Token fountains: bearer credentials that entitle wallets to'
+ ' withdraw blind-signed promotional tokens.';
+ COMMENT ON COLUMN merchant_fountains.fountain_id IS
+ 'Public identifier of the fountain, included in the wallet'
+ ' onboarding URI.';
+ COMMENT ON COLUMN merchant_fountains.h_fountain_secret IS
+ 'Hash of the bearer credential; the secret itself is never stored.';
+ COMMENT ON COLUMN merchant_fountains.description IS
+ 'Human-readable description, preferably an opaque campaign or'
+ ' recipient reference of the institution.';
+ COMMENT ON COLUMN merchant_fountains.poll_freq IS
+ 'How often wallets should re-poll the fountain information.';
+
+ CREATE TABLE merchant_fountain_grants (
+ fountain_grant_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ fountain_serial INT8 NOT NULL,
+ token_family_serial INT8 NOT NULL,
+ tokens_per_period_limit INT8 NOT NULL,
+ tokens_per_period_stash INT8 NOT NULL,
+ key_window_size INT8 NOT NULL,
+ CONSTRAINT merchant_fountain_grants_limit_check
+ CHECK ((tokens_per_period_limit >= 0)),
+ CONSTRAINT merchant_fountain_grants_stash_check
+ CHECK ((tokens_per_period_stash BETWEEN 0 AND tokens_per_period_limit)),
+ CONSTRAINT merchant_fountain_grants_window_check
+ CHECK ((key_window_size >= 0)),
+ CONSTRAINT merchant_fountain_grants_unique
+ UNIQUE (fountain_serial, token_family_serial),
+ CONSTRAINT merchant_fountain_grants_fountain_serial_fkey
+ FOREIGN KEY (fountain_serial)
+ REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE,
+ CONSTRAINT merchant_fountain_grants_token_family_serial_fkey
+ FOREIGN KEY (token_family_serial)
+ REFERENCES merchant_token_families(token_family_serial) ON DELETE CASCADE
+ );
+ COMMENT ON TABLE merchant_fountain_grants IS
+ 'Withdrawal rights of a fountain: which token families may be'
+ ' withdrawn and at what rate.';
+ COMMENT ON COLUMN merchant_fountain_grants.tokens_per_period_limit IS
+ 'Maximum number of tokens blind-signed per issue-key validity'
+ ' period for this token family.';
+ COMMENT ON COLUMN merchant_fountain_grants.tokens_per_period_stash IS
+ 'Number of tokens the wallet should aim to hold per period; a'
+ ' client-side stocking target, at most tokens_per_period_limit.';
+ COMMENT ON COLUMN merchant_fountain_grants.key_window_size IS
+ 'Number of issue-key slots ahead of the current one for which the'
+ ' wallet may withdraw tokens.';
+
+ CREATE TABLE merchant_fountain_withdrawals (
+ fountain_withdrawal_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ fountain_serial INT8 NOT NULL,
+ token_family_serial INT8 NOT NULL,
+ slot_start INT8 NOT NULL,
+ num_withdrawn INT8 DEFAULT 0 NOT NULL,
+ CONSTRAINT merchant_fountain_withdrawals_unique
+ UNIQUE (fountain_serial, token_family_serial, slot_start),
+ CONSTRAINT merchant_fountain_withdrawals_fountain_serial_fkey
+ FOREIGN KEY (fountain_serial)
+ REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE,
+ CONSTRAINT merchant_fountain_withdrawals_token_family_serial_fkey
+ FOREIGN KEY (token_family_serial)
+ REFERENCES merchant_token_families(token_family_serial) ON DELETE CASCADE
+ );
+ COMMENT ON TABLE merchant_fountain_withdrawals IS
+ 'Per-period withdrawal counters enforcing the limits of the'
+ ' fountain grants. Deliberately not referencing'
+ ' merchant_fountain_grants: replacing the grants of a fountain'
+ ' must not reset the quota already consumed.';
+ COMMENT ON COLUMN merchant_fountain_withdrawals.slot_start IS
+ 'signature_validity_start of the issue-key validity period this'
+ ' counter accounts for.';
+ COMMENT ON COLUMN merchant_fountain_withdrawals.num_withdrawn IS
+ 'Number of tokens already blind-signed for this fountain, token'
+ ' family and validity period.';
+
+ CREATE TABLE merchant_fountain_withdraw_sigs (
+ fountain_serial INT8 NOT NULL,
+ h_request BYTEA NOT NULL,
+ grant_index INT4 NOT NULL,
+ token_index INT4 NOT NULL,
+ token_family_slug TEXT NOT NULL,
+ h_issue BYTEA NOT NULL CHECK (LENGTH(h_issue) = 64),
+ signature_validity_end INT8 NOT NULL,
+ token_blinded_signature BYTEA NOT NULL,
+ CONSTRAINT merchant_fountain_withdraw_sigs_h_request_check
+ CHECK ((LENGTH(h_request) = 64)),
+ CONSTRAINT merchant_fountain_withdraw_sigs_pkey
+ PRIMARY KEY (fountain_serial, h_request, grant_index, token_index),
+ CONSTRAINT merchant_fountain_withdraw_sigs_fountain_serial_fkey
+ FOREIGN KEY (fountain_serial)
+ REFERENCES merchant_fountains(fountain_serial) ON DELETE CASCADE
+ );
+ COMMENT ON TABLE merchant_fountain_withdraw_sigs IS
+ 'Blind signatures handed out by a completed withdrawal, committed'
+ ' together with the quota consumption and the issued tokens. A'
+ ' repeated request is answered by rebuilding its response from'
+ ' these rows instead of consuming quota a second time.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.h_request IS
+ 'Hash of the canonical JSON of the grants array of the request.'
+ ' Neither the bearer credential nor unblinded token data is'
+ ' stored.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.grant_index IS
+ 'Offset of the grant this signature belongs to in the request,'
+ ' and hence in the response.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.token_index IS
+ 'Offset of the signature within the token_sigs array of that'
+ ' grant, matching the order of the submitted envelopes.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.token_family_slug IS
+ 'Token family slug returned in the original withdrawal response.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.h_issue IS
+ 'Issue-key hash returned in the original withdrawal response.';
+ COMMENT ON COLUMN merchant_fountain_withdraw_sigs.signature_validity_end IS
+ 'Issue-key expiry at withdrawal time. GC retains the complete request'
+ ' until the latest expiry, independently of family or key deletion.';
+
+ ALTER TABLE merchant_issued_tokens
+ ALTER COLUMN h_contract_terms DROP NOT NULL;
+ COMMENT ON COLUMN merchant_issued_tokens.h_contract_terms IS
+ 'This is no foreign key by design. NULL for tokens issued via a'
+ ' fountain instead of as an output of an order.';
+
SET LOCAL search_path TO merchant;
END
$OUTER$;
INSERT INTO merchant.instance_fixups
- (migration_name
- ,version)
- VALUES
- ('merchant_0048_init'
- ,48);
--- Apply new fix-up to existing instances
+ (migration_name, version)
+ VALUES ('merchant_0048_init', 48);
CALL merchant.fixup_instance_schema (48::INT8);
COMMIT;
diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build
@@ -65,6 +65,9 @@ sql_instance_procedures = [
'../do_insert_account.sql',
'../update_to_account_inactive.sql',
'../do_expire_locks.sql',
+ '../do_insert_fountain.sql',
+ '../do_update_fountain.sql',
+ '../do_fountain_withdraw.sql',
'../pg_merchant_send_account_notification.sql',
'../pg_merchant_account_trigger.sql',
'../pg_merchant_send_kyc_notification.sql',
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -78,6 +78,7 @@
#include "merchant-database/insert_token_family.h"
#include "merchant-database/insert_token_family_key.h"
#include "merchant-database/get_token_family.h"
+#include "merchant-database/get_token_family_key.h"
#include "merchant-database/delete_token_family.h"
#include "merchant-database/get_account_serial.h"
#include "merchant-database/get_contract_terms.h"
@@ -2950,6 +2951,135 @@ test_insert_used_token (const struct TestTokens_Closure *cls,
/**
+ * Serialize a previously empty family across independent connections, then
+ * verify that a waiting creator sees the committed key. Also reject an old
+ * SERIALIZABLE snapshot rather than allowing it to mint from stale data.
+ */
+static int
+test_token_family_key_lock (struct TestTokens_Closure *cls)
+{
+ struct TALER_MERCHANTDB_TokenFamilyKeyDetails kd;
+ bool started;
+ int to_child[2];
+ int from_child[2];
+ pid_t child;
+ int status;
+ char signal;
+
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_lock_token_family (pg,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (started);
+ /* Re-entering must retain the caller's transaction and its lock. */
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_lock_token_family (pg,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (! started);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_get_token_family_key (
+ pg, cls->instance.instance.id, cls->family.slug,
+ cls->family.valid_after, cls->family.valid_before, &kd));
+ GNUNET_assert (NULL == kd.pub.public_key);
+ TALER_MERCHANTDB_token_family_details_free (&kd.token_family);
+ GNUNET_assert (0 == pipe (to_child));
+ GNUNET_assert (0 == pipe (from_child));
+ child = fork ();
+ GNUNET_assert (-1 != child);
+ if (0 == child)
+ {
+ struct TALER_MERCHANTDB_PostgresContext *other;
+ struct GNUNET_PQ_ExecuteStatement timeout[] = {
+ GNUNET_PQ_make_execute ("SET lock_timeout='100ms'"),
+ GNUNET_PQ_EXECUTE_STATEMENT_END
+ };
+
+ close (to_child[1]);
+ close (from_child[0]);
+ alarm (15);
+ other = TALER_MERCHANTDB_connect (test_cfg);
+ GNUNET_assert (NULL != other);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_set_instance (other,
+ cls->instance.instance.id));
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_exec_statements (other->conn,
+ timeout));
+ /* No key exists yet, but the second worker must still block. */
+ GNUNET_assert (0 > TALER_MERCHANTDB_lock_token_family (other,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (started);
+ TALER_MERCHANTDB_rollback (other);
+ GNUNET_assert (1 == write (from_child[1], "B", 1));
+ GNUNET_assert (1 == read (to_child[0], &signal, 1));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_lock_token_family (other,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (started);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_get_token_family_key (
+ other, cls->instance.instance.id, cls->family.slug,
+ cls->family.valid_after, cls->family.valid_before, &kd));
+ GNUNET_assert (NULL != kd.pub.public_key);
+ GNUNET_assert (0 == GNUNET_memcmp (&cls->h_pub.hash,
+ &kd.pub.public_key->pub_key_hash));
+ TALER_MERCHANTDB_token_family_details_free (&kd.token_family);
+ GNUNET_CRYPTO_blind_sign_pub_decref (kd.pub.public_key);
+ GNUNET_CRYPTO_blind_sign_priv_decref (kd.priv.private_key);
+ GNUNET_assert (0 <= TALER_MERCHANTDB_commit (other));
+ /* Establish an old snapshot before the parent's next lock acquisition. */
+ GNUNET_assert (GNUNET_OK == TALER_MERCHANTDB_start (other,
+ "old key snapshot"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_get_token_family_key (
+ other, cls->instance.instance.id, cls->family.slug,
+ cls->family.valid_after, cls->family.valid_before, &kd));
+ TALER_MERCHANTDB_token_family_details_free (&kd.token_family);
+ GNUNET_CRYPTO_blind_sign_pub_decref (kd.pub.public_key);
+ GNUNET_CRYPTO_blind_sign_priv_decref (kd.priv.private_key);
+ GNUNET_assert (1 == write (from_child[1], "S", 1));
+ GNUNET_assert (1 == read (to_child[0], &signal, 1));
+ GNUNET_assert (GNUNET_DB_STATUS_SOFT_ERROR ==
+ TALER_MERCHANTDB_lock_token_family (other,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (! started);
+ TALER_MERCHANTDB_rollback (other);
+ TALER_MERCHANTDB_disconnect (other);
+ _exit (0);
+ }
+ close (to_child[0]);
+ close (from_child[1]);
+ GNUNET_assert (1 == read (from_child[0], &signal, 1));
+ GNUNET_assert ('B' == signal);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_insert_token_family_key (
+ pg, cls->instance.instance.id, cls->family.slug,
+ &cls->pub, &cls->priv, cls->family.valid_before,
+ cls->family.valid_after, cls->family.valid_before));
+ GNUNET_assert (0 <= TALER_MERCHANTDB_commit (pg));
+ GNUNET_assert (1 == write (to_child[1], "C", 1));
+ GNUNET_assert (1 == read (from_child[0], &signal, 1));
+ GNUNET_assert ('S' == signal);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_MERCHANTDB_lock_token_family (pg,
+ cls->family.slug,
+ &started));
+ GNUNET_assert (started);
+ GNUNET_assert (0 <= TALER_MERCHANTDB_commit (pg));
+ GNUNET_assert (1 == write (to_child[1], "C", 1));
+ close (to_child[1]);
+ close (from_child[0]);
+ GNUNET_assert (child == waitpid (child, &status, 0));
+ GNUNET_assert (WIFEXITED (status) && (0 == WEXITSTATUS (status)));
+ return 0;
+}
+
+
+/**
* Sets up the data structures used in the token tests.
*
* @param cls the closure to fill with test data.
@@ -3043,17 +3173,7 @@ run_test_tokens (struct TestTokens_Closure *cls)
"Insert token family failed\n");
TEST_SET_INSTANCE (cls->instance.instance.id,
GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
- TEST_COND_RET_ON_FAIL (
- GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
- TALER_MERCHANTDB_insert_token_family_key (pg,
- cls->instance.instance.id,
- cls->family.slug,
- &cls->pub,
- &cls->priv,
- cls->family.valid_before,
- cls->family.valid_after,
- cls->family.valid_before),
- "Insert token family key failed\n");
+ TEST_RET_ON_FAIL (test_token_family_key_lock (cls));
TEST_RET_ON_FAIL (test_token_family_counters (cls,
0,
0));
@@ -10683,6 +10803,218 @@ test_pending_webhooks (void)
}
+/**
+ * GC must remove expired finite fountain counters even when another
+ * counter has an indefinite duration. An overflow used to roll back
+ * the entire per-instance GC block, preserving the expired counter too.
+ * Replayable withdrawal signatures must be retained as a complete group
+ * until the last issue key of that withdrawal expires.
+ *
+ * @param instance instance for the test
+ * @return 0 on success, 1 otherwise
+ */
+static int
+test_fountain_gc (const struct InstanceData *instance)
+{
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_RET_ON_FAIL (exec_sql (
+ "INSERT INTO merchant_token_families"
+ " (slug, name, description_i18n, valid_after,"
+ " valid_before, duration, kind)"
+ " VALUES ('gc-finite', 'GC finite', '{}', 0,"
+ " 9223372036854775807, 60000000, 'discount'),"
+ " ('gc-forever', 'GC forever', '{}', 0,"
+ " 9223372036854775807, 9223372036854775807,"
+ " 'discount');"
+ "INSERT INTO merchant_fountains"
+ " (fountain_id, h_fountain_secret, description,"
+ " poll_freq)"
+ " VALUES ('gc-fountain',"
+ " decode(repeat('01',64),'hex'),"
+ " 'GC test', 60000000);"
+ "INSERT INTO merchant_fountain_withdrawals"
+ " (fountain_serial, token_family_serial, slot_start,"
+ " num_withdrawn)"
+ " SELECT f.fountain_serial, tf.token_family_serial,"
+ " 1000000, 1"
+ " FROM merchant_fountains f"
+ " CROSS JOIN merchant_token_families tf"
+ " WHERE f.fountain_id='gc-fountain'"
+ " AND tf.slug IN ('gc-finite','gc-forever');"
+ /* One issue key long expired, one still valid. */
+ "INSERT INTO merchant_token_family_keys"
+ " (token_family_serial, pub, h_pub, priv, cipher,"
+ " signature_validity_start, signature_validity_end)"
+ " SELECT tf.token_family_serial, '\\x00'::BYTEA,"
+ " decode(repeat('04',64),'hex'),"
+ " '\\x00'::BYTEA, 'rsa', 0, 1000000"
+ " FROM merchant_token_families tf"
+ " WHERE tf.slug='gc-finite';"
+ "INSERT INTO merchant_token_family_keys"
+ " (token_family_serial, pub, h_pub, priv, cipher,"
+ " signature_validity_start, signature_validity_end)"
+ " SELECT tf.token_family_serial, '\\x00'::BYTEA,"
+ " decode(repeat('05',64),'hex'),"
+ " '\\x00'::BYTEA, 'rsa', 0,"
+ " 9223372036854775807"
+ " FROM merchant_token_families tf"
+ " WHERE tf.slug='gc-forever';"
+ "INSERT INTO merchant_fountain_withdraw_sigs"
+ " (fountain_serial, h_request, grant_index,"
+ " token_index, token_family_slug, h_issue,"
+ " signature_validity_end,"
+ " token_blinded_signature)"
+ " SELECT f.fountain_serial,"
+ " decode(repeat('02',64),'hex'), 0, 0,"
+ " 'gc-finite', k.h_pub, k.signature_validity_end,"
+ " '\\x00'::BYTEA"
+ " FROM merchant_fountains f"
+ " CROSS JOIN merchant_token_family_keys k"
+ " WHERE f.fountain_id='gc-fountain'"
+ " AND k.h_pub=decode(repeat('04',64),'hex');"
+ "INSERT INTO merchant_fountain_withdraw_sigs"
+ " (fountain_serial, h_request, grant_index,"
+ " token_index, token_family_slug, h_issue,"
+ " signature_validity_end,"
+ " token_blinded_signature)"
+ " SELECT f.fountain_serial,"
+ " decode(repeat('03',64),'hex'), 0, 0,"
+ " 'gc-forever', k.h_pub, k.signature_validity_end,"
+ " '\\x00'::BYTEA"
+ " FROM merchant_fountains f"
+ " CROSS JOIN merchant_token_family_keys k"
+ " WHERE f.fountain_id='gc-fountain'"
+ " AND k.h_pub=decode(repeat('05',64),'hex');"
+ /* One request spanning an expired and an indefinite key. */
+ "INSERT INTO merchant_fountain_withdraw_sigs"
+ " (fountain_serial, h_request, grant_index,"
+ " token_index, token_family_slug, h_issue,"
+ " signature_validity_end,"
+ " token_blinded_signature)"
+ " SELECT f.fountain_serial,"
+ " decode(repeat('06',64),'hex'),"
+ " CASE WHEN k.signature_validity_end=1000000"
+ " THEN 0 ELSE 1 END, 0,"
+ " tf.slug, k.h_pub, k.signature_validity_end,"
+ " '\\x00'::BYTEA"
+ " FROM merchant_fountains f"
+ " CROSS JOIN merchant_token_family_keys k"
+ " JOIN merchant_token_families tf USING(token_family_serial)"
+ " WHERE f.fountain_id='gc-fountain'"
+ " AND k.h_pub IN (decode(repeat('04',64),'hex'),"
+ " decode(repeat('05',64),'hex'));"
+ /* The same hash in another fountain is independent. */
+ "INSERT INTO merchant_fountains"
+ " (fountain_id, h_fountain_secret, description, poll_freq)"
+ " VALUES ('gc-other-fountain',"
+ " decode(repeat('07',64),'hex'), 'GC test', 60000000);"
+ "INSERT INTO merchant_fountain_withdraw_sigs"
+ " (fountain_serial, h_request, grant_index, token_index,"
+ " token_family_slug, h_issue, signature_validity_end,"
+ " token_blinded_signature)"
+ " SELECT f.fountain_serial, saved.h_request,"
+ " saved.grant_index, saved.token_index,"
+ " saved.token_family_slug, saved.h_issue,"
+ " saved.signature_validity_end,"
+ " saved.token_blinded_signature"
+ " FROM merchant_fountains f"
+ " CROSS JOIN merchant_fountain_withdraw_sigs saved"
+ " WHERE f.fountain_id='gc-other-fountain'"
+ " AND saved.h_request=decode(repeat('06',64),'hex')"
+ " AND saved.grant_index=0;"));
+ TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg),
+ "Fountain garbage collection failed\n");
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_RET_ON_FAIL (exec_sql (
+ "DO $$ BEGIN"
+ " IF (SELECT count(*)"
+ " FROM merchant_fountain_withdrawals fw"
+ " JOIN merchant_token_families tf"
+ " USING(token_family_serial)"
+ " WHERE tf.slug='gc-finite') <> 0 THEN"
+ " RAISE EXCEPTION"
+ " 'Expired fountain counter survived GC';"
+ " END IF;"
+ " IF (SELECT count(*)"
+ " FROM merchant_fountain_withdrawals fw"
+ " JOIN merchant_token_families tf"
+ " USING(token_family_serial)"
+ " WHERE tf.slug='gc-forever'"
+ " AND num_withdrawn=1) <> 1 THEN"
+ " RAISE EXCEPTION"
+ " 'Indefinite fountain counter was lost';"
+ " END IF;"
+ " IF (SELECT count(*)"
+ " FROM merchant_fountain_withdraw_sigs fws"
+ " WHERE fws.h_request=decode(repeat('02',64),'hex')) <> 0"
+ " THEN"
+ " RAISE EXCEPTION"
+ " 'Expired withdrawal signature survived GC';"
+ " END IF;"
+ " IF (SELECT count(*)"
+ " FROM merchant_fountain_withdraw_sigs fws"
+ " WHERE fws.h_request=decode(repeat('03',64),'hex')) <> 1 THEN"
+ " RAISE EXCEPTION"
+ " 'Replayable withdrawal signature was collected';"
+ " END IF;"
+ " IF (SELECT count(*)"
+ " FROM merchant_fountain_withdraw_sigs fws"
+ " JOIN merchant_fountains f USING(fountain_serial)"
+ " WHERE f.fountain_id='gc-fountain'"
+ " AND fws.h_request=decode(repeat('06',64),'hex')) <> 2 THEN"
+ " RAISE EXCEPTION 'Mixed-expiry replay was partially collected';"
+ " END IF;"
+ " IF EXISTS (SELECT FROM merchant_fountain_withdraw_sigs"
+ " JOIN merchant_fountains USING(fountain_serial)"
+ " WHERE fountain_id='gc-other-fountain') THEN"
+ " RAISE EXCEPTION 'Replay expiry was shared across fountains';"
+ " END IF;"
+ " END $$;"
+ /* Deleting a family must not truncate a saved response. */
+ "DELETE FROM merchant_token_families WHERE slug='gc-finite';"
+ "DO $$ BEGIN"
+ " IF (SELECT count(*) FROM merchant_fountain_withdraw_sigs"
+ " WHERE h_request=decode(repeat('06',64),'hex')) <> 2 THEN"
+ " RAISE EXCEPTION 'Family deletion truncated replay';"
+ " END IF;"
+ " END $$;"));
+ TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg),
+ "Fountain garbage collection failed\n");
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_RET_ON_FAIL (exec_sql (
+ "DO $$ BEGIN"
+ " IF (SELECT count(*) FROM merchant_fountain_withdraw_sigs"
+ " WHERE h_request=decode(repeat('06',64),'hex')) <> 2 THEN"
+ " RAISE EXCEPTION 'GC truncated replay after family deletion';"
+ " END IF;"
+ " END $$;"
+ /* Simulate the last saved expiry passing. */
+ "UPDATE merchant_fountain_withdraw_sigs"
+ " SET signature_validity_end=1000000"
+ " WHERE h_issue=decode(repeat('05',64),'hex');"));
+ TEST_COND_RET_ON_FAIL (GNUNET_OK == TALER_MERCHANTDB_gc (pg),
+ "Fountain garbage collection failed\n");
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_RET_ON_FAIL (exec_sql (
+ "DO $$ BEGIN"
+ " IF EXISTS (SELECT FROM merchant_fountain_withdraw_sigs"
+ " JOIN merchant_fountains USING(fountain_serial)"
+ " WHERE fountain_id='gc-fountain') THEN"
+ " RAISE EXCEPTION 'Expired replay group survived GC';"
+ " END IF;"
+ " END $$;"
+ "DELETE FROM merchant_fountains"
+ " WHERE fountain_id IN ('gc-fountain','gc-other-fountain');"
+ "DELETE FROM merchant_token_families"
+ " WHERE slug IN ('gc-finite','gc-forever');"));
+ return 0;
+}
+
+
/* ********** Statistics ********** */
@@ -11253,6 +11585,7 @@ run_test_statistics (struct TestStatistics_Closure *cls)
TEST_RET_ON_FAIL (test_insert_instance (&cls->instance,
GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
TEST_RET_ON_FAIL (test_statistics_bucket_gc (&cls->instance));
+ TEST_RET_ON_FAIL (test_fountain_gc (&cls->instance));
/* Every amount statistic bumped by a trigger in pg_triggers.sql must
be registered in the per-instance schema. */
TEST_RET_ON_FAIL (test_statistics_amount_slug_collected (
diff --git a/src/backenddb/test_order_sequence_migrations.py b/src/backenddb/test_order_sequence_migrations.py
@@ -306,6 +306,34 @@ class OrderSequenceMigrations(unittest.TestCase):
self.assertEqual(empty.add_order(), 1)
self.assertEqual(new.add_order(), 1)
+ def test_0048_adds_dd97_and_dd98_to_existing_and_new_instances(self):
+ db = self.database_before(47)
+ existing = db.add_instance(1)
+ db.apply_migration(47)
+ db.apply_migration(48)
+ new = db.add_instance(2)
+ for instance in (existing, new):
+ schema = instance.schema
+ self.assertEqual(db.sql(f"""
+ SELECT count(*) FROM information_schema.columns
+ WHERE table_schema='{schema}'
+ AND ((table_name='merchant_otp_devices'
+ AND column_name='otp_device_pub')
+ OR (table_name IN ('merchant_orders', 'merchant_contract_terms')
+ AND column_name='pos_challenge'))
+ """), "3")
+ self.assertEqual(db.sql(f"""
+ SELECT count(*) FROM information_schema.tables
+ WHERE table_schema='{schema}' AND table_name IN
+ ('merchant_fountains', 'merchant_fountain_grants',
+ 'merchant_fountain_withdrawals', 'merchant_fountain_withdraw_sigs')
+ """), "4")
+ self.assertEqual(db.sql(f"""
+ SELECT is_nullable FROM information_schema.columns
+ WHERE table_schema='{schema}' AND table_name='merchant_issued_tokens'
+ AND column_name='h_contract_terms'
+ """), "YES")
+
def test_0047_sequence_restart_rolls_back(self):
db = self.database_before(47)
orders = db.add_instance(1)
diff --git a/src/include/merchant-database/all.h b/src/include/merchant-database/all.h
@@ -9,6 +9,13 @@
#include "merchant-database/insert_kyc_failure.h"
#include "merchant-database/insert_kyc_status.h"
#include "merchant-database/do_insert_account.h"
+#include "merchant-database/do_insert_fountain.h"
+#include "merchant-database/do_update_fountain.h"
+#include "merchant-database/do_fountain_withdraw.h"
+#include "merchant-database/get_fountain.h"
+#include "merchant-database/get_fountain_by_secret.h"
+#include "merchant-database/iterate_fountains.h"
+#include "merchant-database/iterate_fountain_grants.h"
#include "merchant-database/get_exists_donau_instance.h"
#include "merchant-database/get_missing_money_pot.h"
#include "merchant-database/get_report_by_token.h"
@@ -23,6 +30,7 @@
#include "merchant-database/delete_login_token.h"
#include "merchant-database/delete_login_token_by_serial.h"
#include "merchant-database/delete_money_pot.h"
+#include "merchant-database/delete_fountain.h"
#include "merchant-database/delete_order.h"
#include "merchant-database/delete_otp_device.h"
#include "merchant-database/delete_pending_webhook.h"
diff --git a/src/include/merchant-database/delete_fountain.h b/src/include/merchant-database/delete_fountain.h
@@ -0,0 +1,49 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/delete_fountain.h
+ * @brief implementation of the delete_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_DELETE_FOUNTAIN_H
+#define MERCHANT_DATABASE_DELETE_FOUNTAIN_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Delete a fountain, disabling future withdrawals with its
+ * credential. Cascades to its grants and withdrawal counters;
+ * tokens already withdrawn remain valid.
+ *
+ * @param pg database context
+ * @param instance_id instance the fountain belongs to
+ * @param fountain_id public identifier of the fountain to delete
+ * @return database result code;
+ * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if no fountain
+ * with @a fountain_id exists
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_delete_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id);
+
+#endif
diff --git a/src/include/merchant-database/do_fountain_withdraw.h b/src/include/merchant-database/do_fountain_withdraw.h
@@ -0,0 +1,65 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/do_fountain_withdraw.h
+ * @brief implementation of the do_fountain_withdraw function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_DO_FOUNTAIN_WITHDRAW_H
+#define MERCHANT_DATABASE_DO_FOUNTAIN_WITHDRAW_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Atomically check and consume per-period withdrawal quota for a
+ * fountain. Each entry addresses one (token family, key slot) pair;
+ * entries must be distinct. All-or-nothing: if any entry has no
+ * matching grant or would exceed its limit, no quota is consumed and
+ * @a failed_index reports the offending entry.
+ *
+ * @param pg database context
+ * @param instance_id instance the fountain belongs to
+ * @param fountain_serial serial of the fountain withdrawing from
+ * @param num_entries number of entries in the parallel arrays
+ * @param token_family_serials token family of each entry
+ * @param slot_starts issue-key validity period start of each entry
+ * @param num_requested number of tokens requested by each entry
+ * @param[out] failed_index on failure, set to the index (into the
+ * input arrays) of the offending entry
+ * @param[out] no_grant set to true if the failed entry has no
+ * matching grant
+ * @param[out] exceeded set to true if the failed entry would exceed
+ * its tokens_per_period_limit
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_fountain_withdraw (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ uint64_t fountain_serial,
+ unsigned int num_entries,
+ const uint64_t token_family_serials[static num_entries],
+ const struct GNUNET_TIME_Timestamp slot_starts[static num_entries],
+ const uint64_t num_requested[static num_entries],
+ unsigned int *failed_index,
+ bool *no_grant,
+ bool *exceeded);
+
+#endif
diff --git a/src/include/merchant-database/do_insert_fountain.h b/src/include/merchant-database/do_insert_fountain.h
@@ -0,0 +1,91 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/do_insert_fountain.h
+ * @brief implementation of the do_insert_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_DO_INSERT_FOUNTAIN_H
+#define MERCHANT_DATABASE_DO_INSERT_FOUNTAIN_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Withdrawal rights of a fountain for one token family (DD 98).
+ */
+struct TALER_MERCHANTDB_FountainGrant
+{
+ /**
+ * Slug of the token family this grant refers to.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Maximum number of tokens blind-signed per issue-key
+ * validity period for this family.
+ */
+ uint64_t tokens_per_period_limit;
+
+ /**
+ * Number of tokens the wallet should aim to hold per period;
+ * at most @e tokens_per_period_limit.
+ */
+ uint64_t tokens_per_period_stash;
+
+ /**
+ * Number of issue-key slots ahead of the current one the
+ * wallet may withdraw tokens for.
+ */
+ uint32_t key_window_size;
+};
+
+
+/**
+ * Create a fountain with its grants.
+ *
+ * @param pg database context
+ * @param instance_id instance to create the fountain for
+ * @param fountain_id public identifier of the new fountain
+ * @param h_fountain_secret hash of the fountain's bearer credential
+ * @param description description of the fountain
+ * @param poll_freq how often wallets should re-poll the fountain info
+ * @param grants_len length of the @a grants array
+ * @param grants withdrawal rights of the fountain
+ * @param[out] unknown_slug set to the first token family slug in
+ * @a grants that does not exist (caller must free); the
+ * fountain is not created in that case
+ * @param[out] conflict set to true if a fountain with the same
+ * @a fountain_id or @a h_fountain_secret already exists
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_insert_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ const struct GNUNET_HashCode *h_fountain_secret,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int grants_len,
+ const struct TALER_MERCHANTDB_FountainGrant grants[static grants_len],
+ char **unknown_slug,
+ bool *conflict);
+
+#endif
diff --git a/src/include/merchant-database/do_update_fountain.h b/src/include/merchant-database/do_update_fountain.h
@@ -0,0 +1,65 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/do_update_fountain.h
+ * @brief implementation of the do_update_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_DO_UPDATE_FOUNTAIN_H
+#define MERCHANT_DATABASE_DO_UPDATE_FOUNTAIN_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+#include "merchant-database/do_insert_fountain.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Update a fountain. Fields that are NULL keep their previous
+ * value. If @a replace_grants is true, the fountain's grant set is
+ * fully replaced by @a grants; withdrawal counters are deliberately
+ * left untouched so consumed quota survives the replacement.
+ *
+ * @param pg database context
+ * @param instance_id instance the fountain belongs to
+ * @param fountain_id public identifier of the fountain to update
+ * @param description new description, or NULL to keep the previous one
+ * @param poll_freq new poll frequency, or NULL to keep the previous one
+ * @param replace_grants true to replace the grant set with @a grants
+ * @param grants_len length of the @a grants array
+ * @param grants new withdrawal rights of the fountain
+ * @param[out] not_found set to true if no fountain with
+ * @a fountain_id exists
+ * @param[out] unknown_slug set to the first token family slug in
+ * @a grants that does not exist (caller must free); the
+ * fountain is unchanged in that case
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_do_update_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ const char *description,
+ const struct GNUNET_TIME_Relative *poll_freq,
+ bool replace_grants,
+ unsigned int grants_len,
+ const struct TALER_MERCHANTDB_FountainGrant *grants,
+ bool *not_found,
+ char **unknown_slug);
+
+#endif
diff --git a/src/include/merchant-database/get_fountain.h b/src/include/merchant-database/get_fountain.h
@@ -0,0 +1,70 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/get_fountain.h
+ * @brief implementation of the get_fountain function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_H
+#define MERCHANT_DATABASE_GET_FOUNTAIN_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Details about a fountain (DD 98), excluding its grants and
+ * (the hash of) its bearer credential.
+ */
+struct TALER_MERCHANTDB_FountainDetails
+{
+ /**
+ * Serial of the fountain in the database.
+ */
+ uint64_t fountain_serial;
+
+ /**
+ * Description of the fountain. Malloc'ed, caller must free.
+ */
+ char *description;
+
+ /**
+ * How often wallets should re-poll the fountain info.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+};
+
+
+/**
+ * Look up details of a fountain by its public identifier.
+ *
+ * @param pg database context
+ * @param instance_id instance the fountain belongs to
+ * @param fountain_id public identifier of the fountain
+ * @param[out] fd set to the fountain details on success,
+ * `fd->description` must be freed by the caller
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *fountain_id,
+ struct TALER_MERCHANTDB_FountainDetails *fd);
+
+#endif
diff --git a/src/include/merchant-database/get_fountain_by_secret.h b/src/include/merchant-database/get_fountain_by_secret.h
@@ -0,0 +1,53 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/get_fountain_by_secret.h
+ * @brief implementation of the get_fountain_by_secret function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_BY_SECRET_H
+#define MERCHANT_DATABASE_GET_FOUNTAIN_BY_SECRET_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Look up a fountain by the hash of its bearer credential. Used to
+ * authenticate wallet requests to the public fountain endpoints; a
+ * result of #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS means the credential
+ * is unknown (or the fountain was deleted) and the request must be
+ * answered with HTTP 401.
+ *
+ * @param pg database context
+ * @param instance_id instance to look up the fountain in
+ * @param h_fountain_secret hash of the fountain's bearer credential
+ * @param[out] fountain_serial set to the serial of the fountain
+ * @param[out] poll_freq set to how often wallets should re-poll
+ * the fountain info
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain_by_secret (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const struct GNUNET_HashCode *h_fountain_secret,
+ uint64_t *fountain_serial,
+ struct GNUNET_TIME_Relative *poll_freq);
+
+#endif
diff --git a/src/include/merchant-database/get_fountain_withdraw.h b/src/include/merchant-database/get_fountain_withdraw.h
@@ -0,0 +1,68 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+/**
+ * @file src/include/merchant-database/get_fountain_withdraw.h
+ * @brief look up the signatures of a completed fountain withdrawal
+ */
+#ifndef MERCHANT_DATABASE_GET_FOUNTAIN_WITHDRAW_H
+#define MERCHANT_DATABASE_GET_FOUNTAIN_WITHDRAW_H
+#include "merchantdb_lib.h"
+
+/**
+ * Called once per stored signature, ordered by grant and then by
+ * position within the grant, so the response can be rebuilt by
+ * appending.
+ *
+ * @param cls closure
+ * @param grant_index offset of the grant in the original request
+ * @param token_family_slug token family the grant refers to
+ * @param h_issue hash of the issue key the signature was made with
+ * @param blind_sig the blind signature handed out originally
+ */
+typedef void
+(*TALER_MERCHANTDB_FountainWithdrawSigCallback)(
+ void *cls,
+ uint32_t grant_index,
+ const char *token_family_slug,
+ const struct TALER_TokenIssuePublicKeyHashP *h_issue,
+ const struct GNUNET_CRYPTO_BlindedSignature *blind_sig);
+
+/**
+ * Lock the fountain and hand back the signatures of an earlier
+ * withdrawal of the same request, if there was one. Must run in a
+ * READ COMMITTED transaction. The lock is held until commit or
+ * rollback; looking the signatures up after taking it is what makes a
+ * concurrent withdrawal of the same request visible here instead of
+ * being counted twice.
+ *
+ * @param pg database context with the instance selected
+ * @param fountain_serial authenticated fountain
+ * @param h_request hash of the request's grants array
+ * @param[out] not_found true if the fountain was deleted
+ * @param cb called for each stored signature, in response order
+ * @param cb_cls closure for @a cb
+ * @return database result code; no result if the request is new
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_fountain_withdraw (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ uint64_t fountain_serial,
+ const struct GNUNET_HashCode *h_request,
+ bool *not_found,
+ TALER_MERCHANTDB_FountainWithdrawSigCallback cb,
+ void *cb_cls);
+
+#endif
diff --git a/src/include/merchant-database/get_token_family_key.h b/src/include/merchant-database/get_token_family_key.h
@@ -67,6 +67,25 @@ struct TALER_MERCHANTDB_TokenFamilyKeyDetails
/**
+ * Serialize key lookup and creation for a token family. Starts a READ COMMITTED
+ * transaction if none is active. The caller must retain the lock until lookup,
+ * lifetime extension and insertion are complete, and commit or roll back any
+ * transaction started here. Existing transactions remain owned by the caller.
+ *
+ * @param pg database context with the instance selected
+ * @param token_family_slug family to lock
+ * @param[out] started_transaction true if this call started a transaction,
+ * including on failure (the caller must roll it back)
+ * @return database result code; zero if the family does not exist
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_lock_token_family (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *token_family_slug,
+ bool *started_transaction);
+
+
+/**
* Lookup details about a particular token family key. The valid_after field
* of the key has to be >= @e min_valid_after and < @e max_valid_after.
*
diff --git a/src/include/merchant-database/insert_fountain_withdraw_sig.h b/src/include/merchant-database/insert_fountain_withdraw_sig.h
@@ -0,0 +1,50 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+/**
+ * @file src/include/merchant-database/insert_fountain_withdraw_sig.h
+ * @brief record one blind signature of a completed fountain withdrawal
+ */
+#ifndef MERCHANT_DATABASE_INSERT_FOUNTAIN_WITHDRAW_SIG_H
+#define MERCHANT_DATABASE_INSERT_FOUNTAIN_WITHDRAW_SIG_H
+#include "merchantdb_lib.h"
+
+/**
+ * Store one blind signature of a withdrawal so that a repeated request
+ * can be answered from it. Must run in the same transaction as the
+ * quota consumption and the issued-token records, holding the fountain
+ * lock taken by TALER_MERCHANTDB_get_fountain_withdraw().
+ *
+ * @param pg database context with the instance selected
+ * @param fountain_serial authenticated fountain
+ * @param h_request hash of the request's grants array
+ * @param grant_index offset of the grant in the request
+ * @param token_index offset of the signature within that grant
+ * @param h_issue_pub hash of the issue key used; its family slug and expiry
+ * are copied so replay survives deletion of the key or family
+ * @param blind_sig the blind signature handed to the wallet
+ * @return database result code; no result if the issue key is gone
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_insert_fountain_withdraw_sig (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ uint64_t fountain_serial,
+ const struct GNUNET_HashCode *h_request,
+ uint32_t grant_index,
+ uint32_t token_index,
+ const struct TALER_TokenIssuePublicKeyHashP *h_issue_pub,
+ const struct TALER_BlindedTokenIssueSignature *blind_sig);
+
+#endif
diff --git a/src/include/merchant-database/insert_issued_token.h b/src/include/merchant-database/insert_issued_token.h
@@ -29,7 +29,8 @@
struct TALER_MERCHANTDB_PostgresContext;
/**
* @param pg database context
- * @param h_contract_terms hash of the contract the token was issued for
+ * @param h_contract_terms hash of the contract the token was issued for;
+ * NULL for tokens issued via a fountain (DD 98)
* @param h_issue_pub hash of the token issue public key used to sign the issued token
* @param blind_sig resulting blind token issue signature
* @param[out] no_family set to true if the token family key is unknown,
diff --git a/src/include/merchant-database/iterate_fountain_grants.h b/src/include/merchant-database/iterate_fountain_grants.h
@@ -0,0 +1,71 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/iterate_fountain_grants.h
+ * @brief implementation of the iterate_fountain_grants function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_ITERATE_FOUNTAIN_GRANTS_H
+#define MERCHANT_DATABASE_ITERATE_FOUNTAIN_GRANTS_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Typically called by `iterate_fountain_grants`.
+ *
+ * @param cls closure
+ * @param token_family_slug slug of the granted token family
+ * @param token_family_serial serial of the granted token family
+ * @param tokens_per_period_limit maximum withdrawals per issue-key
+ * validity period
+ * @param tokens_per_period_stash suggested number of tokens to hold
+ * per period
+ * @param key_window_size number of issue-key slots ahead the wallet
+ * may withdraw tokens for
+ */
+typedef void
+(*TALER_MERCHANTDB_FountainGrantsCallback)(
+ void *cls,
+ const char *token_family_slug,
+ uint64_t token_family_serial,
+ uint64_t tokens_per_period_limit,
+ uint64_t tokens_per_period_stash,
+ uint32_t key_window_size);
+
+
+/**
+ * Iterate over all grants of a fountain.
+ *
+ * @param pg database context
+ * @param instance_id instance the fountain belongs to
+ * @param fountain_serial serial of the fountain to iterate grants of
+ * @param cb function to call with each grant
+ * @param cb_cls closure for @a cb
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_fountain_grants (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ uint64_t fountain_serial,
+ TALER_MERCHANTDB_FountainGrantsCallback cb,
+ void *cb_cls);
+
+#endif
diff --git a/src/include/merchant-database/iterate_fountains.h b/src/include/merchant-database/iterate_fountains.h
@@ -0,0 +1,60 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ * @file src/include/merchant-database/iterate_fountains.h
+ * @brief implementation of the iterate_fountains function for Postgres
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_ITERATE_FOUNTAINS_H
+#define MERCHANT_DATABASE_ITERATE_FOUNTAINS_H
+
+#include <taler/taler_util.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Typically called by `iterate_fountains`.
+ *
+ * @param cls a `json_t *` JSON array to build
+ * @param fountain_id public identifier of the fountain
+ * @param description description of the fountain
+ */
+typedef void
+(*TALER_MERCHANTDB_FountainsCallback)(
+ void *cls,
+ const char *fountain_id,
+ const char *description);
+
+
+/**
+ * Iterate over all fountains of an instance.
+ *
+ * @param pg database context
+ * @param instance_id instance to iterate fountains of
+ * @param cb function to call with each fountain
+ * @param cb_cls closure for @a cb
+ * @return database result code
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_fountains (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ TALER_MERCHANTDB_FountainsCallback cb,
+ void *cb_cls);
+
+#endif
diff --git a/src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,111 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/delete-private-fountains-FOUNTAIN_ID.h
+ * @brief C interface for the DELETE /private/fountains/$FOUNTAIN_ID endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Handle for a DELETE /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_DeletePrivateFountainHandle;
+
+
+/**
+ * Response details for a DELETE /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_DeletePrivateFountainResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+};
+
+
+#ifndef TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_DeletePrivateFountainCallback.
+ */
+#define TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE */
+
+/**
+ * Callback for a DELETE /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls closure
+ * @param dfr response details
+ */
+typedef void
+(*TALER_MERCHANT_DeletePrivateFountainCallback)(
+ TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_DeletePrivateFountainResponse *dfr);
+
+
+/**
+ * Set up DELETE /private/fountains/$FOUNTAIN_ID operation.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param fountain_id public identifier of the fountain to delete
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_DeletePrivateFountainHandle *
+TALER_MERCHANT_delete_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id);
+
+
+/**
+ * Start DELETE /private/fountains/$FOUNTAIN_ID operation.
+ *
+ * @param[in,out] dpfh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_delete_private_fountain_start (
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh,
+ TALER_MERCHANT_DeletePrivateFountainCallback cb,
+ TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel DELETE /private/fountains/$FOUNTAIN_ID operation. This
+ * function must not be called by clients after the callback has been
+ * invoked.
+ *
+ * @param[in] dpfh operation to cancel
+ */
+void
+TALER_MERCHANT_delete_private_fountain_cancel (
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh);
+
+
+#endif /* _TALER_MERCHANT__DELETE_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */
diff --git a/src/include/taler/merchant/get-fountain-info.h b/src/include/taler/merchant/get-fountain-info.h
@@ -0,0 +1,162 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/get-fountain-info.h
+ * @brief C interface for the (public) GET /fountain/info endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__GET_FOUNTAIN_INFO_H
+#define _TALER_MERCHANT__GET_FOUNTAIN_INFO_H
+
+#include <taler/merchant/common.h>
+#include <taler/merchant/post-private-fountains.h>
+#include <taler/taler_merchant_util.h>
+
+
+/**
+ * Handle for a GET /fountain/info request.
+ */
+struct TALER_MERCHANT_GetFountainInfoHandle;
+
+
+/**
+ * Grant of a fountain as seen by the wallet, including the token
+ * family metadata and issue keys needed to prepare blinded envelopes.
+ */
+struct TALER_MERCHANT_FountainWalletGrant
+{
+ /**
+ * Basic grant parameters.
+ */
+ struct TALER_MERCHANT_FountainGrant grant;
+
+ /**
+ * Token family metadata with the currently valid (and, within the
+ * grant's key window, upcoming) issue public keys, in increasing expiry
+ * order. Intervals can overlap; use a key's valid_after as the explicit
+ * valid_at of a withdrawal to select that key.
+ */
+ struct TALER_MERCHANT_ContractTokenFamily token_family;
+};
+
+
+/**
+ * Response details for a GET /fountain/info request.
+ */
+struct TALER_MERCHANT_GetFountainInfoResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status.
+ */
+ union
+ {
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+ /**
+ * How often the wallet should re-poll this endpoint.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+
+ /**
+ * Array of grants of the fountain.
+ */
+ const struct TALER_MERCHANT_FountainWalletGrant *grants;
+
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int grants_len;
+ } ok;
+ } details;
+
+};
+
+
+#ifndef TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_GetFountainInfoCallback.
+ */
+#define TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE */
+
+/**
+ * Callback for a GET /fountain/info request.
+ *
+ * @param cls closure
+ * @param fir response details
+ */
+typedef void
+(*TALER_MERCHANT_GetFountainInfoCallback)(
+ TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_GetFountainInfoResponse *fir);
+
+
+/**
+ * Set up GET /fountain/info operation. The request is authenticated
+ * with the fountain's bearer credential in the Authorization header.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param fountain_secret the fountain's bearer credential
+ * (Crockford Base32)
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_GetFountainInfoHandle *
+TALER_MERCHANT_get_fountain_info_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_secret);
+
+
+/**
+ * Start GET /fountain/info operation.
+ *
+ * @param[in,out] gfih operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_get_fountain_info_start (
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih,
+ TALER_MERCHANT_GetFountainInfoCallback cb,
+ TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel GET /fountain/info operation. This function must not be
+ * called by clients after the callback has been invoked.
+ *
+ * @param[in] gfih operation to cancel
+ */
+void
+TALER_MERCHANT_get_fountain_info_cancel (
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih);
+
+
+#endif /* _TALER_MERCHANT__GET_FOUNTAIN_INFO_H */
diff --git a/src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,143 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/get-private-fountains-FOUNTAIN_ID.h
+ * @brief C interface for the GET /private/fountains/$FOUNTAIN_ID endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include <taler/merchant/common.h>
+#include <taler/merchant/post-private-fountains.h>
+
+
+/**
+ * Handle for a GET /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_GetPrivateFountainHandle;
+
+
+/**
+ * Response details for a GET /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_GetPrivateFountainResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status.
+ */
+ union
+ {
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+ /**
+ * Description of the fountain.
+ */
+ const char *description;
+
+ /**
+ * How often wallets should re-poll the fountain info.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+
+ /**
+ * Array of grants of the fountain.
+ */
+ const struct TALER_MERCHANT_FountainGrant *grants;
+
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int grants_len;
+ } ok;
+ } details;
+
+};
+
+
+#ifndef TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_GetPrivateFountainCallback.
+ */
+#define TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE */
+
+/**
+ * Callback for a GET /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls closure
+ * @param gfr response details
+ */
+typedef void
+(*TALER_MERCHANT_GetPrivateFountainCallback)(
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_GetPrivateFountainResponse *gfr);
+
+
+/**
+ * Set up GET /private/fountains/$FOUNTAIN_ID operation.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param fountain_id public identifier of the fountain to inspect
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_GetPrivateFountainHandle *
+TALER_MERCHANT_get_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id);
+
+
+/**
+ * Start GET /private/fountains/$FOUNTAIN_ID operation.
+ *
+ * @param[in,out] gpfh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountain_start (
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh,
+ TALER_MERCHANT_GetPrivateFountainCallback cb,
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel GET /private/fountains/$FOUNTAIN_ID operation. This function
+ * must not be called by clients after the callback has been invoked.
+ *
+ * @param[in] gpfh operation to cancel
+ */
+void
+TALER_MERCHANT_get_private_fountain_cancel (
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh);
+
+
+#endif /* _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */
diff --git a/src/include/taler/merchant/get-private-fountains.h b/src/include/taler/merchant/get-private-fountains.h
@@ -0,0 +1,147 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/get-private-fountains.h
+ * @brief C interface for the GET /private/fountains endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H
+#define _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Handle for a GET /private/fountains request.
+ */
+struct TALER_MERCHANT_GetPrivateFountainsHandle;
+
+
+/**
+ * Summary of a fountain in a fountain listing.
+ */
+struct TALER_MERCHANT_FountainEntry
+{
+ /**
+ * Public identifier of the fountain.
+ */
+ const char *fountain_id;
+
+ /**
+ * Description of the fountain.
+ */
+ const char *description;
+};
+
+
+/**
+ * Response details for a GET /private/fountains request.
+ */
+struct TALER_MERCHANT_GetPrivateFountainsResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status.
+ */
+ union
+ {
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+ /**
+ * Array of fountains.
+ */
+ const struct TALER_MERCHANT_FountainEntry *fountains;
+
+ /**
+ * Length of the @e fountains array.
+ */
+ unsigned int fountains_len;
+ } ok;
+ } details;
+
+};
+
+
+#ifndef TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_GetPrivateFountainsCallback.
+ */
+#define TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE */
+
+/**
+ * Callback for a GET /private/fountains request.
+ *
+ * @param cls closure
+ * @param gfr response details
+ */
+typedef void
+(*TALER_MERCHANT_GetPrivateFountainsCallback)(
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_GetPrivateFountainsResponse *gfr);
+
+
+/**
+ * Set up GET /private/fountains operation.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_GetPrivateFountainsHandle *
+TALER_MERCHANT_get_private_fountains_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url);
+
+
+/**
+ * Start GET /private/fountains operation.
+ *
+ * @param[in,out] gpfh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountains_start (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
+ TALER_MERCHANT_GetPrivateFountainsCallback cb,
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel GET /private/fountains operation. This function must not be
+ * called by clients after the callback has been invoked.
+ *
+ * @param[in] gpfh operation to cancel
+ */
+void
+TALER_MERCHANT_get_private_fountains_cancel (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh);
+
+
+#endif /* _TALER_MERCHANT__GET_PRIVATE_FOUNTAINS_H */
diff --git a/src/include/taler/merchant/meson.build b/src/include/taler/merchant/meson.build
@@ -5,6 +5,7 @@ talermerchantinclude_HEADERS = [
'delete-management-instances-INSTANCE.h',
'delete-private-accounts-H_WIRE.h',
'delete-private-donau-DONAU_SERIAL.h',
+ 'delete-private-fountains-FOUNTAIN_ID.h',
'delete-private-orders-ORDER_ID.h',
'delete-private-otp-devices-DEVICE_ID.h',
'delete-private-products-PRODUCT_ID.h',
@@ -15,6 +16,7 @@ talermerchantinclude_HEADERS = [
'delete-private-units-UNIT.h',
'delete-private-webhooks-WEBHOOK_ID.h',
'get-config.h',
+ 'get-fountain-info.h',
'get-management-instances.h',
'get-management-instances-INSTANCE.h',
'get-private-kyc.h',
@@ -24,6 +26,8 @@ talermerchantinclude_HEADERS = [
'get-private-accounts.h',
'get-private-accounts-H_WIRE.h',
'get-private-donau.h',
+ 'get-private-fountains.h',
+ 'get-private-fountains-FOUNTAIN_ID.h',
'get-private-orders.h',
'get-private-orders-ORDER_ID.h',
'get-private-otp-devices.h',
@@ -42,12 +46,14 @@ talermerchantinclude_HEADERS = [
'get-templates-TEMPLATE_ID.h',
'patch-management-instances-INSTANCE.h',
'patch-private-accounts-H_WIRE.h',
+ 'patch-private-fountains-FOUNTAIN_ID.h',
'patch-private-otp-devices-DEVICE_ID.h',
'patch-private-orders-ORDER_ID-forget.h',
'patch-private-products-PRODUCT_ID.h',
'patch-private-templates-TEMPLATE_ID.h',
'patch-private-units-UNIT.h',
'patch-private-webhooks-WEBHOOK_ID.h',
+ 'post-fountain-withdraw.h',
'post-management-instances.h',
'post-management-instances-INSTANCE-auth.h',
'post-orders-ORDER_ID-abort.h',
@@ -58,6 +64,7 @@ talermerchantinclude_HEADERS = [
'post-private-accounts.h',
'post-private-categories.h',
'post-private-donau.h',
+ 'post-private-fountains.h',
'post-private-orders.h',
'post-private-orders-ORDER_ID-refund.h',
'post-private-orders-ORDER_ID-refund-external.h',
@@ -82,5 +89,3 @@ foreach h : talermerchantinclude_HEADERS
)
endforeach
-
-
diff --git a/src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h b/src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h
@@ -0,0 +1,295 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/patch-private-fountains-FOUNTAIN_ID.h
+ * @brief C interface for the PATCH /private/fountains/$FOUNTAIN_ID endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+#define _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H
+
+#include <taler/merchant/common.h>
+#include <taler/merchant/post-private-fountains.h>
+
+
+/**
+ * Handle for a PATCH /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_PatchPrivateFountainHandle;
+
+
+/**
+ * Response details for a PATCH /private/fountains/$FOUNTAIN_ID request.
+ */
+struct TALER_MERCHANT_PatchPrivateFountainResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+};
+
+
+/**
+ * Possible options we can set for the
+ * PATCH /private/fountains/$FOUNTAIN_ID request.
+ */
+enum TALER_MERCHANT_PatchPrivateFountainOption
+{
+ /**
+ * End of list of options.
+ */
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END = 0,
+
+ /**
+ * Set a new description.
+ */
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION,
+
+ /**
+ * Set a new poll frequency.
+ */
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ,
+
+ /**
+ * Replace the grant set.
+ */
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS
+
+};
+
+
+/**
+ * Value for an option for the PATCH /private/fountains/$FOUNTAIN_ID
+ * request.
+ */
+struct TALER_MERCHANT_PatchPrivateFountainOptionValue
+{
+
+ /**
+ * Type of the option being set.
+ */
+ enum TALER_MERCHANT_PatchPrivateFountainOption option;
+
+ /**
+ * Specific option value.
+ */
+ union
+ {
+
+ /**
+ * Value if @e option is
+ * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION.
+ */
+ const char *description;
+
+ /**
+ * Value if @e option is
+ * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+
+ /**
+ * Value if @e option is
+ * #TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS.
+ */
+ struct
+ {
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int num_grants;
+
+ /**
+ * New grant set of the fountain.
+ */
+ const struct TALER_MERCHANT_FountainGrant *grants;
+ } grants;
+
+ } details;
+
+};
+
+
+/**
+ * Set up PATCH /private/fountains/$FOUNTAIN_ID operation.
+ * Note that you must explicitly start the operation after
+ * possibly setting options.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param fountain_id public identifier of the fountain to modify
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_PatchPrivateFountainHandle *
+TALER_MERCHANT_patch_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id);
+
+
+/**
+ * Terminate the list of the options.
+ *
+ * @return the terminating object of struct TALER_MERCHANT_PatchPrivateFountainOptionValue
+ */
+#define TALER_MERCHANT_patch_private_fountain_option_end_() \
+ (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \
+ { \
+ .option = TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END \
+ }
+
+/**
+ * Set a new description.
+ *
+ * @param d the new description
+ * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue
+ */
+#define TALER_MERCHANT_patch_private_fountain_option_description(d) \
+ (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \
+ { \
+ .option = \
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION, \
+ .details.description = (d) \
+ }
+
+/**
+ * Set a new poll frequency.
+ *
+ * @param p the new poll frequency
+ * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue
+ */
+#define TALER_MERCHANT_patch_private_fountain_option_poll_freq(p) \
+ (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \
+ { \
+ .option = \
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ, \
+ .details.poll_freq = (p) \
+ }
+
+/**
+ * Replace the grant set.
+ *
+ * @param n length of the @a g array
+ * @param g the new grant set
+ * @return representation of the option as a struct TALER_MERCHANT_PatchPrivateFountainOptionValue
+ */
+#define TALER_MERCHANT_patch_private_fountain_option_grants(n,g) \
+ (const struct TALER_MERCHANT_PatchPrivateFountainOptionValue) \
+ { \
+ .option = \
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS, \
+ .details.grants.num_grants = (n), \
+ .details.grants.grants = (g) \
+ }
+
+
+/**
+ * Set the requested options for the operation.
+ *
+ * If any option fail other options may be or may be not applied.
+ *
+ * @param ppfh the request to set the options for
+ * @param num_options length of the @a options array
+ * @param options an array of options
+ * @return #GNUNET_OK on success,
+ * #GNUNET_NO on failure,
+ * #GNUNET_SYSERR on internal error
+ */
+enum GNUNET_GenericReturnValue
+TALER_MERCHANT_patch_private_fountain_set_options_ (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh,
+ unsigned int num_options,
+ const struct TALER_MERCHANT_PatchPrivateFountainOptionValue *options);
+
+
+/**
+ * Set the requested options for the operation.
+ *
+ * It should be used with helpers that create required options, for example:
+ *
+ * TALER_MERCHANT_patch_private_fountain_set_options (
+ * ppfh,
+ * TALER_MERCHANT_patch_private_fountain_option_description ("new"));
+ *
+ * @param ppfh the request to set the options for
+ * @param ... the list of the options, each option must be created
+ * by helpers TALER_MERCHANT_patch_private_fountain_option_NAME(VALUE)
+ * @return #GNUNET_OK on success,
+ * #GNUNET_NO on failure,
+ * #GNUNET_SYSERR on internal error
+ */
+#define TALER_MERCHANT_patch_private_fountain_set_options(ppfh,...) \
+ TALER_MERCHANT_patch_private_fountain_set_options_ ( \
+ ppfh, \
+ TALER_MERCHANT_COMMON_OPTIONS_ARRAY_MAX_SIZE, \
+ ((const struct TALER_MERCHANT_PatchPrivateFountainOptionValue[]) \
+ {__VA_ARGS__, TALER_MERCHANT_patch_private_fountain_option_end_ () \
+ } \
+ ))
+
+
+#ifndef TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_PatchPrivateFountainCallback.
+ */
+#define TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE */
+
+/**
+ * Callback for a PATCH /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls closure
+ * @param pfr response details
+ */
+typedef void
+(*TALER_MERCHANT_PatchPrivateFountainCallback)(
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_PatchPrivateFountainResponse *pfr);
+
+
+/**
+ * Start PATCH /private/fountains/$FOUNTAIN_ID operation.
+ *
+ * @param[in,out] ppfh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_patch_private_fountain_start (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh,
+ TALER_MERCHANT_PatchPrivateFountainCallback cb,
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel PATCH /private/fountains/$FOUNTAIN_ID operation. This
+ * function must not be called by clients after the callback has been
+ * invoked.
+ *
+ * @param[in] ppfh operation to cancel
+ */
+void
+TALER_MERCHANT_patch_private_fountain_cancel (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh);
+
+
+#endif /* _TALER_MERCHANT__PATCH_PRIVATE_FOUNTAINS_FOUNTAIN_ID_H */
diff --git a/src/include/taler/merchant/post-fountain-withdraw.h b/src/include/taler/merchant/post-fountain-withdraw.h
@@ -0,0 +1,199 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/post-fountain-withdraw.h
+ * @brief C interface for the (public) POST /fountain/withdraw endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H
+#define _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Handle for a POST /fountain/withdraw request.
+ */
+struct TALER_MERCHANT_PostFountainWithdrawHandle;
+
+
+/**
+ * One entry of a fountain withdraw request: envelopes for one
+ * token family and issue key slot.
+ */
+struct TALER_MERCHANT_FountainWithdrawEntry
+{
+ /**
+ * Slug of the token family to withdraw from.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Desired token validity time. Use an advertised key's
+ * signature_validity_start to select that key even when an earlier key
+ * overlaps it. Otherwise the first advertised key covering the time is
+ * selected. Use a zero timestamp to omit and select using the current
+ * time. No key may start after now + key_window_size * duration.
+ */
+ struct GNUNET_TIME_Timestamp valid_at;
+
+ /**
+ * Number of envelopes in @e envelopes.
+ */
+ unsigned int num_envelopes;
+
+ /**
+ * Blinded token envelopes to have signed.
+ */
+ const struct TALER_TokenEnvelope *envelopes;
+};
+
+
+/**
+ * Result for one entry of a fountain withdraw request.
+ */
+struct TALER_MERCHANT_FountainWithdrawResult
+{
+ /**
+ * Slug of the token family the signatures belong to.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Hash of the issue public key that was used.
+ */
+ struct TALER_TokenIssuePublicKeyHashP h_issue;
+
+ /**
+ * Number of signatures in @e token_sigs; matches the number of
+ * envelopes of the corresponding request entry.
+ */
+ unsigned int num_sigs;
+
+ /**
+ * Blind signatures over the envelopes, in request order.
+ * The JSON token_sigs array uses the same blind_sig wrapper as
+ * the order payment response.
+ */
+ const struct TALER_BlindedTokenIssueSignature *token_sigs;
+};
+
+
+/**
+ * Response details for a POST /fountain/withdraw request.
+ */
+struct TALER_MERCHANT_PostFountainWithdrawResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status.
+ */
+ union
+ {
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+ /**
+ * Array of per-entry results, in request order.
+ */
+ const struct TALER_MERCHANT_FountainWithdrawResult *results;
+
+ /**
+ * Length of the @e results array.
+ */
+ unsigned int results_len;
+ } ok;
+ } details;
+
+};
+
+
+#ifndef TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_PostFountainWithdrawCallback.
+ */
+#define TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE */
+
+/**
+ * Callback for a POST /fountain/withdraw request.
+ *
+ * @param cls closure
+ * @param fwr response details
+ */
+typedef void
+(*TALER_MERCHANT_PostFountainWithdrawCallback)(
+ TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr);
+
+
+/**
+ * Set up POST /fountain/withdraw operation.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param fountain_secret the fountain's bearer credential
+ * (Crockford Base32)
+ * @param num_entries length of the @a entries array
+ * @param entries withdrawal entries
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_PostFountainWithdrawHandle *
+TALER_MERCHANT_post_fountain_withdraw_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_secret,
+ unsigned int num_entries,
+ const struct TALER_MERCHANT_FountainWithdrawEntry *entries);
+
+
+/**
+ * Start POST /fountain/withdraw operation.
+ *
+ * @param[in,out] pfwh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_post_fountain_withdraw_start (
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh,
+ TALER_MERCHANT_PostFountainWithdrawCallback cb,
+ TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel POST /fountain/withdraw operation. This function must not
+ * be called by clients after the callback has been invoked.
+ *
+ * @param[in] pfwh operation to cancel
+ */
+void
+TALER_MERCHANT_post_fountain_withdraw_cancel (
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh);
+
+
+#endif /* _TALER_MERCHANT__POST_FOUNTAIN_WITHDRAW_H */
diff --git a/src/include/taler/merchant/post-private-fountains.h b/src/include/taler/merchant/post-private-fountains.h
@@ -0,0 +1,173 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/post-private-fountains.h
+ * @brief C interface for the POST /private/fountains endpoint (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H
+#define _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Withdrawal rights of a fountain for one token family (DD 98).
+ */
+struct TALER_MERCHANT_FountainGrant
+{
+ /**
+ * Slug of the token family this grant refers to.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Maximum number of tokens blind-signed per issue-key validity
+ * period for this family.
+ */
+ uint64_t tokens_per_period_limit;
+
+ /**
+ * Number of tokens the wallet should aim to hold per period;
+ * at most @e tokens_per_period_limit.
+ */
+ uint64_t tokens_per_period_stash;
+
+ /**
+ * Maximum number of future issue keys in addition to the current key.
+ * No key may start after now + key_window_size * duration.
+ * Each successive key is selected just after the previous key expires,
+ * using the token family's normal duration and rounding rules. The
+ * advertised window can be shorter if family bounds or rounding prevent
+ * further coverage. Zero allows only the current key.
+ */
+ uint32_t key_window_size;
+};
+
+
+/**
+ * Handle for a POST /private/fountains request.
+ */
+struct TALER_MERCHANT_PostPrivateFountainsHandle;
+
+
+/**
+ * Response details for a POST /private/fountains request.
+ */
+struct TALER_MERCHANT_PostPrivateFountainsResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status.
+ */
+ union
+ {
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+ /**
+ * Public identifier of the new fountain.
+ */
+ const char *fountain_id;
+
+ /**
+ * Bearer credential of the new fountain, Crockford Base32.
+ * Returned exactly once; the backend stores only its hash.
+ */
+ const char *fountain_secret;
+ } ok;
+ } details;
+
+};
+
+
+#ifndef TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_PostPrivateFountainsCallback.
+ */
+#define TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE */
+
+/**
+ * Callback for a POST /private/fountains request.
+ *
+ * @param cls closure
+ * @param pfr response details
+ */
+typedef void
+(*TALER_MERCHANT_PostPrivateFountainsCallback)(
+ TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_PostPrivateFountainsResponse *pfr);
+
+
+/**
+ * Set up POST /private/fountains operation.
+ * Note that you must explicitly start the operation.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param description description of the fountain
+ * @param poll_freq how often wallets should re-poll the fountain info
+ * @param num_grants length of the @a grants array
+ * @param grants withdrawal rights of the fountain
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_PostPrivateFountainsHandle *
+TALER_MERCHANT_post_private_fountains_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int num_grants,
+ const struct TALER_MERCHANT_FountainGrant *grants);
+
+
+/**
+ * Start POST /private/fountains operation.
+ *
+ * @param[in,out] ppfh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_fountains_start (
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh,
+ TALER_MERCHANT_PostPrivateFountainsCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel POST /private/fountains operation. This function must not be
+ * called by clients after the callback has been invoked.
+ *
+ * @param[in] ppfh operation to cancel
+ */
+void
+TALER_MERCHANT_post_private_fountains_cancel (
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh);
+
+
+#endif /* _TALER_MERCHANT__POST_PRIVATE_FOUNTAINS_H */
diff --git a/src/include/taler/taler_merchant_service.h b/src/include/taler/taler_merchant_service.h
@@ -75,6 +75,13 @@
#include <taler/merchant/post-private-accounts.h>
#include <taler/merchant/patch-private-accounts-H_WIRE.h>
#include <taler/merchant/delete-private-accounts-H_WIRE.h>
+#include <taler/merchant/get-private-fountains.h>
+#include <taler/merchant/get-private-fountains-FOUNTAIN_ID.h>
+#include <taler/merchant/post-private-fountains.h>
+#include <taler/merchant/patch-private-fountains-FOUNTAIN_ID.h>
+#include <taler/merchant/delete-private-fountains-FOUNTAIN_ID.h>
+#include <taler/merchant/get-fountain-info.h>
+#include <taler/merchant/post-fountain-withdraw.h>
#include <taler/merchant/get-private-otp-devices.h>
#include <taler/merchant/get-private-otp-devices-DEVICE_ID.h>
#include <taler/merchant/post-private-otp-devices.h>
diff --git a/src/include/taler/taler_merchant_testing_lib.h b/src/include/taler/taler_merchant_testing_lib.h
@@ -2320,6 +2320,62 @@ TALER_TESTING_cmd_merchant_post_tokenfamilies (
struct GNUNET_TIME_Relative rounding,
const char *kind);
+/* ****** Fountains (DD 98) ******* */
+
+/**
+ * Define a "POST /private/fountains" CMD.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the request.
+ * @param http_status expected HTTP response code.
+ * @param description description of the fountain.
+ * @param poll_freq poll frequency of the fountain.
+ * @param num_grants length of the @a grants array.
+ * @param grants grants of the fountain; must remain valid for the
+ * lifetime of the command.
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_fountains (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int num_grants,
+ const struct TALER_MERCHANT_FountainGrant *grants);
+
+
+/**
+ * Define a CMD simulating a wallet withdrawing tokens from a
+ * fountain: fetches GET /fountain/info, prepares blinded envelopes,
+ * runs POST /fountain/withdraw and unblinds and verifies the
+ * resulting tokens. The withdrawn tokens are offered via the
+ * indexed token_priv/token_issue_pub/token_issue_sig traits, so a
+ * pay command can spend them via its token reference.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the request.
+ * @param http_status expected HTTP response code of the withdraw
+ * request.
+ * @param fountain_reference label of the command offering the
+ * fountain secret (e.g. a "POST /private/fountains" command).
+ * @param token_family_slug slug of the token family to withdraw from.
+ * @param valid_at desired validity time of the tokens; zero for "now".
+ * @param num_tokens number of tokens to withdraw.
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_fountain_withdraw (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *fountain_reference,
+ const char *token_family_slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ unsigned int num_tokens);
+
+
/* ****** Webhooks ******* */
@@ -2733,7 +2789,9 @@ TALER_TESTING_cmd_exec_donaukeyupdate (const char *label,
op (summary, const char) \
op (token_family_slug, const char) \
op (token_family_duration, const struct GNUNET_TIME_Relative) \
- op (token_family_kind, const char)
+ op (token_family_kind, const char) \
+ op (fountain_id, const char) \
+ op (fountain_secret, const char)
/**
diff --git a/src/include/taler/taler_merchant_util.h b/src/include/taler/taler_merchant_util.h
@@ -1861,8 +1861,25 @@ TALER_MERCHANT_spec_contract_choices (
/**
+ * Maximum number of issue keys accepted per token family by the shared JSON
+ * parser. This resource limit also applies to fountain-info responses.
+ */
+#define TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS 1024
+
+
+/**
+ * Maximum number of expected or trusted domains accepted per token family by
+ * the shared JSON parser.
+ */
+#define TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS 1024
+
+
+/**
* Provide specification to parse given JSON array to token families in the
* contract terms. All fields from @a families items are copied.
+ * Rejects keys and domain arrays above #TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS
+ * and #TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS, respectively, before allocating
+ * their storage.
*
* @param name name of the token families field in the JSON
* @param[out] families where the token families array has to be written
diff --git a/src/lib/merchant_api_common.h b/src/lib/merchant_api_common.h
@@ -26,6 +26,14 @@
/**
+ * Resource limit for fountain response grants and total withdrawal signatures.
+ * Exceeds the backend's limits (192 grants and 64 envelopes), while bounding
+ * allocations even for malformed responses from an untrusted merchant.
+ */
+#define TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS 1024
+
+
+/**
* Function called when we're done processing a
* HTTP POST request to create an order.
*
diff --git a/src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,209 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_delete-private-fountains-FOUNTAIN_ID.c
+ * @brief Implementation of the DELETE /private/fountains/$FOUNTAIN_ID request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/delete-private-fountains-FOUNTAIN_ID.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a DELETE /private/fountains/$FOUNTAIN_ID operation.
+ */
+struct TALER_MERCHANT_DeletePrivateFountainHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Fountain identifier.
+ */
+ char *fountain_id;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_DeletePrivateFountainCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+};
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP DELETE /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls the `struct TALER_MERCHANT_DeletePrivateFountainHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_delete_fountain_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_DeletePrivateFountainResponse dfr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ dpfh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "DELETE /private/fountains/$ID completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ dfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_NO_CONTENT:
+ break;
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ dfr.hr.ec = TALER_JSON_get_error_code (json);
+ dfr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &dfr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) dfr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ dpfh->cb (dpfh->cb_cls,
+ &dfr);
+ TALER_MERCHANT_delete_private_fountain_cancel (dpfh);
+}
+
+
+struct TALER_MERCHANT_DeletePrivateFountainHandle *
+TALER_MERCHANT_delete_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id)
+{
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh;
+
+ dpfh = GNUNET_new (struct TALER_MERCHANT_DeletePrivateFountainHandle);
+ dpfh->ctx = ctx;
+ dpfh->base_url = GNUNET_strdup (url);
+ dpfh->fountain_id = GNUNET_strdup (fountain_id);
+ return dpfh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_delete_private_fountain_start (
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh,
+ TALER_MERCHANT_DeletePrivateFountainCallback cb,
+ TALER_MERCHANT_DELETE_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ dpfh->cb = cb;
+ dpfh->cb_cls = cb_cls;
+ {
+ char *path;
+
+ GNUNET_asprintf (&path,
+ "private/fountains/%s",
+ dpfh->fountain_id);
+ dpfh->url = TALER_url_join (dpfh->base_url,
+ path,
+ NULL);
+ GNUNET_free (path);
+ }
+ if (NULL == dpfh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (dpfh->url);
+ if (NULL == eh)
+ {
+ GNUNET_break (0);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ GNUNET_assert (CURLE_OK ==
+ curl_easy_setopt (eh,
+ CURLOPT_CUSTOMREQUEST,
+ MHD_HTTP_METHOD_DELETE));
+ dpfh->job = GNUNET_CURL_job_add (dpfh->ctx,
+ eh,
+ &handle_delete_fountain_finished,
+ dpfh);
+ if (NULL == dpfh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_delete_private_fountain_cancel (
+ struct TALER_MERCHANT_DeletePrivateFountainHandle *dpfh)
+{
+ if (NULL != dpfh->job)
+ {
+ GNUNET_CURL_job_cancel (dpfh->job);
+ dpfh->job = NULL;
+ }
+ GNUNET_free (dpfh->fountain_id);
+ GNUNET_free (dpfh->url);
+ GNUNET_free (dpfh->base_url);
+ GNUNET_free (dpfh);
+}
+
+
+/* end of merchant_api_delete-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/lib/merchant_api_get-fountain-info.c b/src/lib/merchant_api_get-fountain-info.c
@@ -0,0 +1,350 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_get-fountain-info.c
+ * @brief Implementation of the (public) GET /fountain/info request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/get-fountain-info.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a GET /fountain/info operation.
+ */
+struct TALER_MERCHANT_GetFountainInfoHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * HTTP headers (Authorization) for the request.
+ */
+ struct curl_slist *headers;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_GetFountainInfoCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+};
+
+
+/**
+ * Parse the fountain info and invoke the callback.
+ *
+ * @param gfih operation handle
+ * @param[in,out] fir response to complete and pass to the callback
+ */
+static void
+handle_ok (struct TALER_MERCHANT_GetFountainInfoHandle *gfih,
+ struct TALER_MERCHANT_GetFountainInfoResponse *fir)
+{
+ const json_t *jgrants;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_relative_time ("poll_freq",
+ &fir->details.ok.poll_freq),
+ GNUNET_JSON_spec_array_const ("grants",
+ &jgrants),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (fir->hr.reply,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ fir->hr.http_status = 0;
+ fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gfih->cb (gfih->cb_cls,
+ fir);
+ return;
+ }
+ if (json_array_size (jgrants) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS)
+ {
+ fir->hr.http_status = 0;
+ fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gfih->cb (gfih->cb_cls,
+ fir);
+ return;
+ }
+ {
+ unsigned int len = (unsigned int) json_array_size (jgrants);
+ struct TALER_MERCHANT_FountainWalletGrant *grants
+ = GNUNET_new_array (GNUNET_NZL (len),
+ struct TALER_MERCHANT_FountainWalletGrant);
+ unsigned int parsed = 0;
+ bool fail = false;
+ size_t idx;
+ json_t *jg;
+
+ json_array_foreach ((json_t *) jgrants, idx, jg)
+ {
+ struct TALER_MERCHANT_FountainWalletGrant *wg = &grants[idx];
+ const json_t *jfamily;
+ uint32_t window;
+ struct GNUNET_JSON_Specification ispec[] = {
+ GNUNET_JSON_spec_string ("token_family_slug",
+ &wg->grant.token_family_slug),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_limit",
+ &wg->grant.tokens_per_period_limit),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_stash",
+ &wg->grant.tokens_per_period_stash),
+ GNUNET_JSON_spec_uint32 ("key_window_size",
+ &window),
+ GNUNET_JSON_spec_object_const ("token_family",
+ &jfamily),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (jg,
+ ispec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ fail = true;
+ break;
+ }
+ wg->grant.key_window_size = window;
+ /* The single-family parser expects an object mapping slugs to
+ families (as in contract terms); wrap accordingly. */
+ {
+ json_t *jwrap;
+ struct TALER_MERCHANT_ContractTokenFamily *families = NULL;
+ unsigned int families_len = 0;
+ struct GNUNET_JSON_Specification wspec[] = {
+ TALER_MERCHANT_spec_token_families (NULL,
+ &families,
+ &families_len),
+ GNUNET_JSON_spec_end ()
+ };
+
+ jwrap = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_object_incref (
+ wg->grant.token_family_slug,
+ (json_t *) jfamily));
+ if ( (GNUNET_OK !=
+ GNUNET_JSON_parse (jwrap,
+ wspec,
+ NULL,
+ NULL)) ||
+ (1 != families_len) )
+ {
+ GNUNET_break_op (0);
+ json_decref (jwrap);
+ for (unsigned int i = 0; i < families_len; i++)
+ TALER_MERCHANT_contract_token_family_free (&families[i]);
+ GNUNET_free (families);
+ fail = true;
+ break;
+ }
+ json_decref (jwrap);
+ wg->token_family = families[0];
+ GNUNET_free (families);
+ }
+ parsed++;
+ }
+ if (fail)
+ {
+ for (unsigned int i = 0; i < parsed; i++)
+ TALER_MERCHANT_contract_token_family_free (&grants[i].token_family);
+ GNUNET_free (grants);
+ fir->hr.http_status = 0;
+ fir->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gfih->cb (gfih->cb_cls,
+ fir);
+ return;
+ }
+ fir->details.ok.grants = grants;
+ fir->details.ok.grants_len = len;
+ gfih->cb (gfih->cb_cls,
+ fir);
+ for (unsigned int i = 0; i < len; i++)
+ TALER_MERCHANT_contract_token_family_free (&grants[i].token_family);
+ GNUNET_free (grants);
+ }
+}
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP GET /fountain/info request.
+ *
+ * @param cls the `struct TALER_MERCHANT_GetFountainInfoHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_get_fountain_info_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_GetFountainInfoResponse fir = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ gfih->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "GET /fountain/info completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ fir.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ handle_ok (gfih,
+ &fir);
+ TALER_MERCHANT_get_fountain_info_cancel (gfih);
+ return;
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ fir.hr.ec = TALER_JSON_get_error_code (json);
+ fir.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &fir.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) fir.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ gfih->cb (gfih->cb_cls,
+ &fir);
+ TALER_MERCHANT_get_fountain_info_cancel (gfih);
+}
+
+
+struct TALER_MERCHANT_GetFountainInfoHandle *
+TALER_MERCHANT_get_fountain_info_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_secret)
+{
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih;
+
+ gfih = GNUNET_new (struct TALER_MERCHANT_GetFountainInfoHandle);
+ gfih->ctx = ctx;
+ gfih->base_url = GNUNET_strdup (url);
+ {
+ char *hdr;
+
+ GNUNET_asprintf (&hdr,
+ "%s: Bearer %s",
+ MHD_HTTP_HEADER_AUTHORIZATION,
+ fountain_secret);
+ gfih->headers = curl_slist_append (NULL,
+ hdr);
+ GNUNET_free (hdr);
+ }
+ return gfih;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_get_fountain_info_start (
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih,
+ TALER_MERCHANT_GetFountainInfoCallback cb,
+ TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ gfih->cb = cb;
+ gfih->cb_cls = cb_cls;
+ gfih->url = TALER_url_join (gfih->base_url,
+ "fountain/info",
+ NULL);
+ if (NULL == gfih->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (gfih->url);
+ if (NULL == eh)
+ {
+ GNUNET_break (0);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ gfih->job = GNUNET_CURL_job_add2 (gfih->ctx,
+ eh,
+ gfih->headers,
+ &handle_get_fountain_info_finished,
+ gfih);
+ if (NULL == gfih->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_get_fountain_info_cancel (
+ struct TALER_MERCHANT_GetFountainInfoHandle *gfih)
+{
+ if (NULL != gfih->job)
+ {
+ GNUNET_CURL_job_cancel (gfih->job);
+ gfih->job = NULL;
+ }
+ curl_slist_free_all (gfih->headers);
+ GNUNET_free (gfih->url);
+ GNUNET_free (gfih->base_url);
+ GNUNET_free (gfih);
+}
+
+
+/* end of merchant_api_get-fountain-info.c */
diff --git a/src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,299 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_get-private-fountains-FOUNTAIN_ID.c
+ * @brief Implementation of the GET /private/fountains/$FOUNTAIN_ID request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/get-private-fountains-FOUNTAIN_ID.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a GET /private/fountains/$FOUNTAIN_ID operation.
+ */
+struct TALER_MERCHANT_GetPrivateFountainHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Fountain identifier.
+ */
+ char *fountain_id;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_GetPrivateFountainCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+};
+
+
+/**
+ * Parse the fountain details and invoke the callback.
+ *
+ * @param gpfh operation handle
+ * @param[in,out] gfr response to complete and pass to the callback
+ */
+static void
+handle_ok (struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh,
+ struct TALER_MERCHANT_GetPrivateFountainResponse *gfr)
+{
+ const json_t *jgrants;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("description",
+ &gfr->details.ok.description),
+ GNUNET_JSON_spec_relative_time ("poll_freq",
+ &gfr->details.ok.poll_freq),
+ GNUNET_JSON_spec_array_const ("grants",
+ &jgrants),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (gfr->hr.reply,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ if (json_array_size (jgrants) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS)
+ {
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ {
+ unsigned int len = (unsigned int) json_array_size (jgrants);
+ struct TALER_MERCHANT_FountainGrant *grants
+ = GNUNET_new_array (GNUNET_NZL (len),
+ struct TALER_MERCHANT_FountainGrant);
+ size_t idx;
+ json_t *jg;
+
+ json_array_foreach ((json_t *) jgrants, idx, jg)
+ {
+ struct TALER_MERCHANT_FountainGrant *fg = &grants[idx];
+ uint32_t window;
+ struct GNUNET_JSON_Specification ispec[] = {
+ GNUNET_JSON_spec_string ("token_family_slug",
+ &fg->token_family_slug),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_limit",
+ &fg->tokens_per_period_limit),
+ GNUNET_JSON_spec_uint64 ("tokens_per_period_stash",
+ &fg->tokens_per_period_stash),
+ GNUNET_JSON_spec_uint32 ("key_window_size",
+ &window),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (jg,
+ ispec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ GNUNET_free (grants);
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ fg->key_window_size = window;
+ }
+ gfr->details.ok.grants = grants;
+ gfr->details.ok.grants_len = len;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ GNUNET_free (grants);
+ }
+}
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP GET /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls the `struct TALER_MERCHANT_GetPrivateFountainHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_get_fountain_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_GetPrivateFountainResponse gfr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ gpfh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "GET /private/fountains/$ID completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ gfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ handle_ok (gpfh,
+ &gfr);
+ TALER_MERCHANT_get_private_fountain_cancel (gpfh);
+ return;
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ gfr.hr.ec = TALER_JSON_get_error_code (json);
+ gfr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &gfr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) gfr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ gpfh->cb (gpfh->cb_cls,
+ &gfr);
+ TALER_MERCHANT_get_private_fountain_cancel (gpfh);
+}
+
+
+struct TALER_MERCHANT_GetPrivateFountainHandle *
+TALER_MERCHANT_get_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id)
+{
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh;
+
+ gpfh = GNUNET_new (struct TALER_MERCHANT_GetPrivateFountainHandle);
+ gpfh->ctx = ctx;
+ gpfh->base_url = GNUNET_strdup (url);
+ gpfh->fountain_id = GNUNET_strdup (fountain_id);
+ return gpfh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountain_start (
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh,
+ TALER_MERCHANT_GetPrivateFountainCallback cb,
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ gpfh->cb = cb;
+ gpfh->cb_cls = cb_cls;
+ {
+ char *path;
+
+ GNUNET_asprintf (&path,
+ "private/fountains/%s",
+ gpfh->fountain_id);
+ gpfh->url = TALER_url_join (gpfh->base_url,
+ path,
+ NULL);
+ GNUNET_free (path);
+ }
+ if (NULL == gpfh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (gpfh->url);
+ if (NULL == eh)
+ {
+ GNUNET_break (0);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ gpfh->job = GNUNET_CURL_job_add (gpfh->ctx,
+ eh,
+ &handle_get_fountain_finished,
+ gpfh);
+ if (NULL == gpfh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_get_private_fountain_cancel (
+ struct TALER_MERCHANT_GetPrivateFountainHandle *gpfh)
+{
+ if (NULL != gpfh->job)
+ {
+ GNUNET_CURL_job_cancel (gpfh->job);
+ gpfh->job = NULL;
+ }
+ GNUNET_free (gpfh->fountain_id);
+ GNUNET_free (gpfh->url);
+ GNUNET_free (gpfh->base_url);
+ GNUNET_free (gpfh);
+}
+
+
+/* end of merchant_api_get-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/lib/merchant_api_get-private-fountains.c b/src/lib/merchant_api_get-private-fountains.c
@@ -0,0 +1,277 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_get-private-fountains.c
+ * @brief Implementation of the GET /private/fountains request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/get-private-fountains.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a GET /private/fountains operation.
+ */
+struct TALER_MERCHANT_GetPrivateFountainsHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_GetPrivateFountainsCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+};
+
+
+/**
+ * Parse the "fountains" array and invoke the callback.
+ *
+ * @param gpfh operation handle
+ * @param[in,out] gfr response to complete and pass to the callback
+ */
+static void
+handle_ok (struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
+ struct TALER_MERCHANT_GetPrivateFountainsResponse *gfr)
+{
+ const json_t *jfountains;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_array_const ("fountains",
+ &jfountains),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (gfr->hr.reply,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ /* This endpoint is not paginated, so bound allocation bytes rather than
+ imposing the much smaller limit used for grants within one fountain.
+ Check before narrowing the count or multiplying it by the entry size. */
+ if (json_array_size (jfountains) >=
+ GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry))
+ {
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ {
+ unsigned int len = (unsigned int) json_array_size (jfountains);
+ struct TALER_MERCHANT_FountainEntry *fountains
+ = GNUNET_new_array (GNUNET_NZL (len),
+ struct TALER_MERCHANT_FountainEntry);
+ size_t idx;
+ json_t *jf;
+
+ json_array_foreach ((json_t *) jfountains, idx, jf)
+ {
+ struct TALER_MERCHANT_FountainEntry *fe = &fountains[idx];
+ struct GNUNET_JSON_Specification ispec[] = {
+ GNUNET_JSON_spec_string ("fountain_id",
+ &fe->fountain_id),
+ GNUNET_JSON_spec_string ("description",
+ &fe->description),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (jf,
+ ispec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ gfr->hr.http_status = 0;
+ gfr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ GNUNET_free (fountains);
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ return;
+ }
+ }
+ gfr->details.ok.fountains = fountains;
+ gfr->details.ok.fountains_len = len;
+ gpfh->cb (gpfh->cb_cls,
+ gfr);
+ GNUNET_free (fountains);
+ }
+}
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP GET /private/fountains request.
+ *
+ * @param cls the `struct TALER_MERCHANT_GetPrivateFountainsHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_get_fountains_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_GetPrivateFountainsResponse gfr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ gpfh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "GET /private/fountains completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ gfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ handle_ok (gpfh,
+ &gfr);
+ TALER_MERCHANT_get_private_fountains_cancel (gpfh);
+ return;
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ gfr.hr.ec = TALER_JSON_get_error_code (json);
+ gfr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &gfr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) gfr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ gpfh->cb (gpfh->cb_cls,
+ &gfr);
+ TALER_MERCHANT_get_private_fountains_cancel (gpfh);
+}
+
+
+struct TALER_MERCHANT_GetPrivateFountainsHandle *
+TALER_MERCHANT_get_private_fountains_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url)
+{
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh;
+
+ gpfh = GNUNET_new (struct TALER_MERCHANT_GetPrivateFountainsHandle);
+ gpfh->ctx = ctx;
+ gpfh->base_url = GNUNET_strdup (url);
+ return gpfh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_get_private_fountains_start (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh,
+ TALER_MERCHANT_GetPrivateFountainsCallback cb,
+ TALER_MERCHANT_GET_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ gpfh->cb = cb;
+ gpfh->cb_cls = cb_cls;
+ gpfh->url = TALER_url_join (gpfh->base_url,
+ "private/fountains",
+ NULL);
+ if (NULL == gpfh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (gpfh->url);
+ if (NULL == eh)
+ {
+ GNUNET_break (0);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ gpfh->job = GNUNET_CURL_job_add (gpfh->ctx,
+ eh,
+ &handle_get_fountains_finished,
+ gpfh);
+ if (NULL == gpfh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_get_private_fountains_cancel (
+ struct TALER_MERCHANT_GetPrivateFountainsHandle *gpfh)
+{
+ if (NULL != gpfh->job)
+ {
+ GNUNET_CURL_job_cancel (gpfh->job);
+ gpfh->job = NULL;
+ }
+ GNUNET_free (gpfh->url);
+ GNUNET_free (gpfh->base_url);
+ GNUNET_free (gpfh);
+}
+
+
+/* end of merchant_api_get-private-fountains.c */
diff --git a/src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c b/src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c
@@ -0,0 +1,303 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_patch-private-fountains-FOUNTAIN_ID.c
+ * @brief Implementation of the PATCH /private/fountains/$FOUNTAIN_ID request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/patch-private-fountains-FOUNTAIN_ID.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a PATCH /private/fountains/$FOUNTAIN_ID operation.
+ */
+struct TALER_MERCHANT_PatchPrivateFountainHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Fountain identifier.
+ */
+ char *fountain_id;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_PatchPrivateFountainCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+
+ /**
+ * Minor context that holds body and headers.
+ */
+ struct TALER_CURL_PostContext post_ctx;
+
+ /**
+ * Request body being assembled from the options.
+ */
+ json_t *req_obj;
+};
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP PATCH /private/fountains/$FOUNTAIN_ID request.
+ *
+ * @param cls the `struct TALER_MERCHANT_PatchPrivateFountainHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_patch_fountain_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_PatchPrivateFountainResponse pfr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ ppfh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "PATCH /private/fountains/$ID completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ pfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_NO_CONTENT:
+ break;
+ case MHD_HTTP_BAD_REQUEST:
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ pfr.hr.ec = TALER_JSON_get_error_code (json);
+ pfr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &pfr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) pfr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ ppfh->cb (ppfh->cb_cls,
+ &pfr);
+ TALER_MERCHANT_patch_private_fountain_cancel (ppfh);
+}
+
+
+struct TALER_MERCHANT_PatchPrivateFountainHandle *
+TALER_MERCHANT_patch_private_fountain_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_id)
+{
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh;
+
+ ppfh = GNUNET_new (struct TALER_MERCHANT_PatchPrivateFountainHandle);
+ ppfh->ctx = ctx;
+ ppfh->base_url = GNUNET_strdup (url);
+ ppfh->fountain_id = GNUNET_strdup (fountain_id);
+ ppfh->req_obj = json_object ();
+ GNUNET_assert (NULL != ppfh->req_obj);
+ return ppfh;
+}
+
+
+enum GNUNET_GenericReturnValue
+TALER_MERCHANT_patch_private_fountain_set_options_ (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh,
+ unsigned int num_options,
+ const struct TALER_MERCHANT_PatchPrivateFountainOptionValue *options)
+{
+ for (unsigned int i = 0; i < num_options; i++)
+ {
+ switch (options[i].option)
+ {
+ case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_END:
+ return GNUNET_OK;
+ case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_DESCRIPTION:
+ GNUNET_assert (0 ==
+ json_object_set_new (
+ ppfh->req_obj,
+ "description",
+ json_string (options[i].details.description)));
+ break;
+ case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_POLL_FREQ:
+ GNUNET_assert (0 ==
+ json_object_set_new (
+ ppfh->req_obj,
+ "poll_freq",
+ GNUNET_JSON_from_time_rel (
+ options[i].details.poll_freq)));
+ break;
+ case TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_OPTION_GRANTS:
+ {
+ json_t *jgrants;
+
+ jgrants = json_array ();
+ GNUNET_assert (NULL != jgrants);
+ for (unsigned int j = 0;
+ j < options[i].details.grants.num_grants;
+ j++)
+ {
+ const struct TALER_MERCHANT_FountainGrant *fg
+ = &options[i].details.grants.grants[j];
+
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jgrants,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string (
+ "token_family_slug",
+ fg->token_family_slug),
+ GNUNET_JSON_pack_uint64 (
+ "tokens_per_period_limit",
+ fg->tokens_per_period_limit),
+ GNUNET_JSON_pack_uint64 (
+ "tokens_per_period_stash",
+ fg->tokens_per_period_stash),
+ GNUNET_JSON_pack_uint64 (
+ "key_window_size",
+ fg->key_window_size))));
+ }
+ GNUNET_assert (0 ==
+ json_object_set_new (ppfh->req_obj,
+ "grants",
+ jgrants));
+ break;
+ }
+ default:
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ }
+ return GNUNET_OK;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_patch_private_fountain_start (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh,
+ TALER_MERCHANT_PatchPrivateFountainCallback cb,
+ TALER_MERCHANT_PATCH_PRIVATE_FOUNTAIN_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ ppfh->cb = cb;
+ ppfh->cb_cls = cb_cls;
+ {
+ char *path;
+
+ GNUNET_asprintf (&path,
+ "private/fountains/%s",
+ ppfh->fountain_id);
+ ppfh->url = TALER_url_join (ppfh->base_url,
+ path,
+ NULL);
+ GNUNET_free (path);
+ }
+ if (NULL == ppfh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (ppfh->url);
+ if ( (NULL == eh) ||
+ (GNUNET_OK !=
+ TALER_curl_easy_post (&ppfh->post_ctx,
+ eh,
+ ppfh->req_obj)) )
+ {
+ GNUNET_break (0);
+ if (NULL != eh)
+ curl_easy_cleanup (eh);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ GNUNET_assert (CURLE_OK ==
+ curl_easy_setopt (eh,
+ CURLOPT_CUSTOMREQUEST,
+ MHD_HTTP_METHOD_PATCH));
+ ppfh->job = GNUNET_CURL_job_add2 (ppfh->ctx,
+ eh,
+ ppfh->post_ctx.headers,
+ &handle_patch_fountain_finished,
+ ppfh);
+ if (NULL == ppfh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_patch_private_fountain_cancel (
+ struct TALER_MERCHANT_PatchPrivateFountainHandle *ppfh)
+{
+ if (NULL != ppfh->job)
+ {
+ GNUNET_CURL_job_cancel (ppfh->job);
+ ppfh->job = NULL;
+ }
+ TALER_curl_easy_post_finished (&ppfh->post_ctx);
+ json_decref (ppfh->req_obj);
+ GNUNET_free (ppfh->fountain_id);
+ GNUNET_free (ppfh->url);
+ GNUNET_free (ppfh->base_url);
+ GNUNET_free (ppfh);
+}
+
+
+/* end of merchant_api_patch-private-fountains-FOUNTAIN_ID.c */
diff --git a/src/lib/merchant_api_post-fountain-withdraw.c b/src/lib/merchant_api_post-fountain-withdraw.c
@@ -0,0 +1,402 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_post-fountain-withdraw.c
+ * @brief Implementation of the (public) POST /fountain/withdraw request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/post-fountain-withdraw.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a POST /fountain/withdraw operation.
+ */
+struct TALER_MERCHANT_PostFountainWithdrawHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_PostFountainWithdrawCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+
+ /**
+ * Minor context that holds body and headers.
+ */
+ struct TALER_CURL_PostContext post_ctx;
+
+ /**
+ * Request body.
+ */
+ json_t *req_obj;
+};
+
+
+/**
+ * Parse the withdraw results and invoke the callback.
+ *
+ * @param pfwh operation handle
+ * @param[in,out] fwr response to complete and pass to the callback
+ */
+static void
+handle_ok (struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh,
+ struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr)
+{
+ const json_t *jresults;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_array_const ("grants",
+ &jresults),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (fwr->hr.reply,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ fwr->hr.http_status = 0;
+ fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ pfwh->cb (pfwh->cb_cls,
+ fwr);
+ return;
+ }
+ if (json_array_size (jresults) > TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS)
+ {
+ fwr->hr.http_status = 0;
+ fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ pfwh->cb (pfwh->cb_cls,
+ fwr);
+ return;
+ }
+ {
+ unsigned int len = (unsigned int) json_array_size (jresults);
+ struct TALER_MERCHANT_FountainWithdrawResult *results;
+ struct TALER_BlindedTokenIssueSignature *all_sigs = NULL;
+ unsigned int num_all_sigs = 0;
+ bool fail = false;
+ size_t idx;
+ json_t *jr;
+
+ /* Bound the total before adding, narrowing or allocating. */
+ json_array_foreach ((json_t *) jresults, idx, jr)
+ {
+ const json_t *jsigs = json_object_get (jr,
+ "token_sigs");
+
+ if ( (! json_is_array (jsigs)) ||
+ (json_array_size (jsigs) >
+ TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS - num_all_sigs) )
+ {
+ fwr->hr.http_status = 0;
+ fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ pfwh->cb (pfwh->cb_cls,
+ fwr);
+ return;
+ }
+ num_all_sigs += (unsigned int) json_array_size (jsigs);
+ }
+ results = GNUNET_new_array (GNUNET_NZL (len),
+ struct TALER_MERCHANT_FountainWithdrawResult);
+ all_sigs = GNUNET_new_array (GNUNET_NZL (num_all_sigs),
+ struct TALER_BlindedTokenIssueSignature);
+ num_all_sigs = 0;
+ json_array_foreach ((json_t *) jresults, idx, jr)
+ {
+ struct TALER_MERCHANT_FountainWithdrawResult *res = &results[idx];
+ const json_t *jsigs;
+ struct GNUNET_JSON_Specification ispec[] = {
+ GNUNET_JSON_spec_string ("token_family_slug",
+ &res->token_family_slug),
+ GNUNET_JSON_spec_fixed_auto ("h_issue",
+ &res->h_issue),
+ GNUNET_JSON_spec_array_const ("token_sigs",
+ &jsigs),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (jr,
+ ispec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ fail = true;
+ break;
+ }
+ res->token_sigs = &all_sigs[num_all_sigs];
+ res->num_sigs = (unsigned int) json_array_size (jsigs);
+ {
+ size_t sidx;
+ json_t *js;
+
+ json_array_foreach ((json_t *) jsigs, sidx, js)
+ {
+ struct GNUNET_JSON_Specification sspec[] = {
+ GNUNET_JSON_spec_blinded_signature (
+ "blind_sig",
+ &all_sigs[num_all_sigs].signature),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (js,
+ sspec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ fail = true;
+ break;
+ }
+ num_all_sigs++;
+ }
+ }
+ if (fail)
+ break;
+ }
+ if (fail)
+ {
+ fwr->hr.http_status = 0;
+ fwr->hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ pfwh->cb (pfwh->cb_cls,
+ fwr);
+ }
+ else
+ {
+ fwr->details.ok.results = results;
+ fwr->details.ok.results_len = len;
+ pfwh->cb (pfwh->cb_cls,
+ fwr);
+ }
+ for (unsigned int i = 0; i < num_all_sigs; i++)
+ if (NULL != all_sigs[i].signature)
+ GNUNET_CRYPTO_blinded_sig_decref (all_sigs[i].signature);
+ GNUNET_free (all_sigs);
+ GNUNET_free (results);
+ }
+}
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP POST /fountain/withdraw request.
+ *
+ * @param cls the `struct TALER_MERCHANT_PostFountainWithdrawHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_post_fountain_withdraw_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_PostFountainWithdrawResponse fwr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ pfwh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "POST /fountain/withdraw completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ fwr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ handle_ok (pfwh,
+ &fwr);
+ TALER_MERCHANT_post_fountain_withdraw_cancel (pfwh);
+ return;
+ case MHD_HTTP_BAD_REQUEST:
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_CONFLICT:
+ case MHD_HTTP_TOO_MANY_REQUESTS:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ fwr.hr.ec = TALER_JSON_get_error_code (json);
+ fwr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &fwr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) fwr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ pfwh->cb (pfwh->cb_cls,
+ &fwr);
+ TALER_MERCHANT_post_fountain_withdraw_cancel (pfwh);
+}
+
+
+struct TALER_MERCHANT_PostFountainWithdrawHandle *
+TALER_MERCHANT_post_fountain_withdraw_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *fountain_secret,
+ unsigned int num_entries,
+ const struct TALER_MERCHANT_FountainWithdrawEntry *entries)
+{
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh;
+ json_t *jentries;
+
+ jentries = json_array ();
+ GNUNET_assert (NULL != jentries);
+ for (unsigned int i = 0; i < num_entries; i++)
+ {
+ const struct TALER_MERCHANT_FountainWithdrawEntry *we = &entries[i];
+ json_t *jenvelopes;
+
+ jenvelopes = json_array ();
+ GNUNET_assert (NULL != jenvelopes);
+ for (unsigned int j = 0; j < we->num_envelopes; j++)
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jenvelopes,
+ GNUNET_JSON_PACK (
+ TALER_JSON_pack_token_envelope (NULL,
+ &we->envelopes[j]))));
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jentries,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ we->token_family_slug),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_TIME_absolute_is_zero (
+ we->valid_at.abs_time)
+ ? GNUNET_JSON_pack_string ("valid_at",
+ NULL)
+ : GNUNET_JSON_pack_timestamp ("valid_at",
+ we->valid_at)),
+ GNUNET_JSON_pack_array_steal ("envelopes",
+ jenvelopes))));
+ }
+ pfwh = GNUNET_new (struct TALER_MERCHANT_PostFountainWithdrawHandle);
+ pfwh->ctx = ctx;
+ pfwh->base_url = GNUNET_strdup (url);
+ pfwh->req_obj = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("fountain_secret",
+ fountain_secret),
+ GNUNET_JSON_pack_array_steal ("grants",
+ jentries));
+ return pfwh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_post_fountain_withdraw_start (
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh,
+ TALER_MERCHANT_PostFountainWithdrawCallback cb,
+ TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ pfwh->cb = cb;
+ pfwh->cb_cls = cb_cls;
+ pfwh->url = TALER_url_join (pfwh->base_url,
+ "fountain/withdraw",
+ NULL);
+ if (NULL == pfwh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (pfwh->url);
+ if ( (NULL == eh) ||
+ (GNUNET_OK !=
+ TALER_curl_easy_post (&pfwh->post_ctx,
+ eh,
+ pfwh->req_obj)) )
+ {
+ GNUNET_break (0);
+ if (NULL != eh)
+ curl_easy_cleanup (eh);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ pfwh->job = GNUNET_CURL_job_add2 (pfwh->ctx,
+ eh,
+ pfwh->post_ctx.headers,
+ &handle_post_fountain_withdraw_finished,
+ pfwh);
+ if (NULL == pfwh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_post_fountain_withdraw_cancel (
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *pfwh)
+{
+ if (NULL != pfwh->job)
+ {
+ GNUNET_CURL_job_cancel (pfwh->job);
+ pfwh->job = NULL;
+ }
+ TALER_curl_easy_post_finished (&pfwh->post_ctx);
+ json_decref (pfwh->req_obj);
+ GNUNET_free (pfwh->url);
+ GNUNET_free (pfwh->base_url);
+ GNUNET_free (pfwh);
+}
+
+
+/* end of merchant_api_post-fountain-withdraw.c */
diff --git a/src/lib/merchant_api_post-private-fountains.c b/src/lib/merchant_api_post-private-fountains.c
@@ -0,0 +1,260 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU Lesser General Public License as
+ published by the Free Software Foundation; either version 2.1,
+ or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General
+ Public License along with TALER; see the file COPYING.LGPL.
+ If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_post-private-fountains.c
+ * @brief Implementation of the POST /private/fountains request
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/post-private-fountains.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a POST /private/fountains operation.
+ */
+struct TALER_MERCHANT_PostPrivateFountainsHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_PostPrivateFountainsCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+
+ /**
+ * Minor context that holds body and headers.
+ */
+ struct TALER_CURL_PostContext post_ctx;
+
+ /**
+ * Request body.
+ */
+ json_t *req_obj;
+};
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP POST /private/fountains request.
+ *
+ * @param cls the `struct TALER_MERCHANT_PostPrivateFountainsHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_post_fountains_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_PostPrivateFountainsResponse pfr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ ppfh->job = NULL;
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "POST /private/fountains completed with response code %u\n",
+ (unsigned int) response_code);
+ switch (response_code)
+ {
+ case 0:
+ pfr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ {
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("fountain_id",
+ &pfr.details.ok.fountain_id),
+ GNUNET_JSON_spec_string ("fountain_secret",
+ &pfr.details.ok.fountain_secret),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (json,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ pfr.hr.http_status = 0;
+ pfr.hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED;
+ }
+ break;
+ }
+ case MHD_HTTP_BAD_REQUEST:
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ case MHD_HTTP_INTERNAL_SERVER_ERROR:
+ pfr.hr.ec = TALER_JSON_get_error_code (json);
+ pfr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &pfr.hr);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u/%d\n",
+ (unsigned int) response_code,
+ (int) pfr.hr.ec);
+ GNUNET_break_op (0);
+ break;
+ }
+ ppfh->cb (ppfh->cb_cls,
+ &pfr);
+ TALER_MERCHANT_post_private_fountains_cancel (ppfh);
+}
+
+
+struct TALER_MERCHANT_PostPrivateFountainsHandle *
+TALER_MERCHANT_post_private_fountains_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int num_grants,
+ const struct TALER_MERCHANT_FountainGrant *grants)
+{
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh;
+ json_t *jgrants;
+
+ jgrants = json_array ();
+ GNUNET_assert (NULL != jgrants);
+ for (unsigned int i = 0; i < num_grants; i++)
+ {
+ GNUNET_assert (0 ==
+ json_array_append_new (
+ jgrants,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug",
+ grants[i].token_family_slug),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_limit",
+ grants[i].
+ tokens_per_period_limit),
+ GNUNET_JSON_pack_uint64 ("tokens_per_period_stash",
+ grants[i].
+ tokens_per_period_stash),
+ GNUNET_JSON_pack_uint64 ("key_window_size",
+ grants[i].key_window_size))));
+ }
+ ppfh = GNUNET_new (struct TALER_MERCHANT_PostPrivateFountainsHandle);
+ ppfh->ctx = ctx;
+ ppfh->base_url = GNUNET_strdup (url);
+ ppfh->req_obj = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("description",
+ description),
+ GNUNET_JSON_pack_time_rel ("poll_freq",
+ poll_freq),
+ GNUNET_JSON_pack_array_steal ("grants",
+ jgrants));
+ return ppfh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_fountains_start (
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh,
+ TALER_MERCHANT_PostPrivateFountainsCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ ppfh->cb = cb;
+ ppfh->cb_cls = cb_cls;
+ ppfh->url = TALER_url_join (ppfh->base_url,
+ "private/fountains",
+ NULL);
+ if (NULL == ppfh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (ppfh->url);
+ if ( (NULL == eh) ||
+ (GNUNET_OK !=
+ TALER_curl_easy_post (&ppfh->post_ctx,
+ eh,
+ ppfh->req_obj)) )
+ {
+ GNUNET_break (0);
+ if (NULL != eh)
+ curl_easy_cleanup (eh);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ ppfh->job = GNUNET_CURL_job_add2 (ppfh->ctx,
+ eh,
+ ppfh->post_ctx.headers,
+ &handle_post_fountains_finished,
+ ppfh);
+ if (NULL == ppfh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_post_private_fountains_cancel (
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *ppfh)
+{
+ if (NULL != ppfh->job)
+ {
+ GNUNET_CURL_job_cancel (ppfh->job);
+ ppfh->job = NULL;
+ }
+ TALER_curl_easy_post_finished (&ppfh->post_ctx);
+ json_decref (ppfh->req_obj);
+ GNUNET_free (ppfh->url);
+ GNUNET_free (ppfh->base_url);
+ GNUNET_free (ppfh);
+}
+
+
+/* end of merchant_api_post-private-fountains.c */
diff --git a/src/lib/meson.build b/src/lib/meson.build
@@ -6,6 +6,7 @@ libtalermerchant_la_SOURCES = [
'merchant_api_common_mfa_challenge.c',
'merchant_api_delete-management-instances-INSTANCE.c',
'merchant_api_delete-private-accounts-H_WIRE.c',
+ 'merchant_api_delete-private-fountains-FOUNTAIN_ID.c',
'merchant_api_delete-private-orders-ORDER_ID.c',
'merchant_api_delete-private-otp-devices-DEVICE_ID.c',
'merchant_api_delete-private-products-PRODUCT_ID.c',
@@ -16,11 +17,14 @@ libtalermerchant_la_SOURCES = [
'merchant_api_delete-private-units-UNIT.c',
'merchant_api_delete-private-webhooks-WEBHOOK_ID.c',
'merchant_api_get-config.c',
+ 'merchant_api_get-fountain-info.c',
'merchant_api_get-management-instances.c',
'merchant_api_get-management-instances-INSTANCE.c',
'merchant_api_get-orders-ORDER_ID.c',
'merchant_api_get-private-accounts.c',
'merchant_api_get-private-accounts-H_WIRE.c',
+ 'merchant_api_get-private-fountains.c',
+ 'merchant_api_get-private-fountains-FOUNTAIN_ID.c',
'merchant_api_get-private-kyc.c',
'merchant_api_get-private-orders.c',
'merchant_api_get-private-orders-ORDER_ID.c',
@@ -42,12 +46,14 @@ libtalermerchant_la_SOURCES = [
'merchant_api_get-templates-TEMPLATE_ID.c',
'merchant_api_patch-management-instances-INSTANCE.c',
'merchant_api_patch-private-accounts-H_WIRE.c',
+ 'merchant_api_patch-private-fountains-FOUNTAIN_ID.c',
'merchant_api_patch-private-orders-ORDER_ID-forget.c',
'merchant_api_patch-private-otp-devices-DEVICE_ID.c',
'merchant_api_patch-private-products-PRODUCT_ID.c',
'merchant_api_patch-private-templates-TEMPLATE_ID.c',
'merchant_api_patch-private-units-UNIT.c',
'merchant_api_patch-private-webhooks-WEBHOOK_ID.c',
+ 'merchant_api_post-fountain-withdraw.c',
'merchant_api_post-management-instances.c',
'merchant_api_post-management-instances-INSTANCE-auth.c',
'merchant_api_post-orders-ORDER_ID-abort.c',
@@ -57,6 +63,7 @@ libtalermerchant_la_SOURCES = [
'merchant_api_post-orders-ORDER_ID-refund.c',
'merchant_api_post-private-accounts.c',
'merchant_api_post-private-categories.c',
+ 'merchant_api_post-private-fountains.c',
'merchant_api_post-private-orders.c',
'merchant_api_post-private-orders-ORDER_ID-refund.c',
'merchant_api_post-private-orders-ORDER_ID-refund-external.c',
@@ -85,6 +92,7 @@ libtalermerchant = library(
dependencies: [
donau_dep,
donaujson_dep,
+ libtalermerchantutil_dep,
talerexchange_dep,
talercurl_dep,
talerkyclogic_dep,
@@ -109,6 +117,20 @@ pkg.generate(
)
+test_fountain_parsers = executable(
+ 'test_fountain_parsers',
+ 'test_fountain_parsers.c',
+ dependencies: [
+ libtalermerchant_dep, libtalermerchantutil_dep,
+ talerjson_dep, talerutil_dep, talercurl_dep,
+ gnunetjson_dep, gnunetutil_dep, gnunetcurl_dep, json_dep, curl_dep,
+ ],
+ include_directories: [incdir, configuration_inc],
+ install: false,
+)
+test('test_fountain_parsers', test_fountain_parsers, suite: ['merchant'])
+
+
talermerchant_tests = ['test_merchant_api_common']
talermerchant_tests_installcheck = []
diff --git a/src/lib/test_fountain_parsers.c b/src/lib/test_fountain_parsers.c
@@ -0,0 +1,328 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free
+ Software Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for
+ more details. You should have received a copy along with TALER; see
+ COPYING.LGPL. If not, see <http://www.gnu.org/licenses/>.
+*/
+/**
+ * @file lib/test_fountain_parsers.c
+ * @brief Exercise the HTTP-200 parsers directly, including hostile array sizes.
+ */
+#define handle_ok info_handle_ok
+#include "merchant_api_get-fountain-info.c"
+#undef handle_ok
+#define handle_ok withdraw_handle_ok
+#include "merchant_api_post-fountain-withdraw.c"
+#undef handle_ok
+#define handle_ok list_handle_ok
+#include "merchant_api_get-private-fountains.c"
+#undef handle_ok
+#define handle_ok detail_handle_ok
+#include "merchant_api_get-private-fountains-FOUNTAIN_ID.c"
+#undef handle_ok
+#include <sys/resource.h>
+
+struct Expected
+{
+ unsigned int count;
+ unsigned int calls;
+ bool malformed;
+};
+
+static void
+info_cb (void *cls,
+ const struct TALER_MERCHANT_GetFountainInfoResponse *r)
+{
+ struct Expected *e = cls;
+
+ e->calls++;
+ GNUNET_assert (e->malformed ? (0 == r->hr.http_status &&
+ TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec)
+ : (MHD_HTTP_OK == r->hr.http_status &&
+ e->count == r->details.ok.grants_len));
+ if ( (! e->malformed) && (0 != e->count) )
+ GNUNET_assert (0 == strcmp (r->details.ok.grants[0].token_family.name,
+ "Test"));
+}
+
+
+static void
+withdraw_cb (void *cls,
+ const struct TALER_MERCHANT_PostFountainWithdrawResponse *r)
+{
+ struct Expected *e = cls;
+
+ e->calls++;
+ GNUNET_assert (e->malformed ? (0 == r->hr.http_status &&
+ TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec)
+ : (MHD_HTTP_OK == r->hr.http_status &&
+ e->count == r->details.ok.results_len));
+ if ( (! e->malformed) && (0 != e->count) )
+ GNUNET_assert (0 == strcmp (r->details.ok.results[0].token_family_slug,
+ "test"));
+}
+
+
+static void
+list_cb (void *cls,
+ const struct TALER_MERCHANT_GetPrivateFountainsResponse *r)
+{
+ struct Expected *e = cls;
+
+ e->calls++;
+ GNUNET_assert (e->malformed ? (0 == r->hr.http_status &&
+ TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec)
+ : (MHD_HTTP_OK == r->hr.http_status &&
+ e->count == r->details.ok.fountains_len));
+ if ( (! e->malformed) && (0 != e->count) )
+ GNUNET_assert (0 == strcmp (r->details.ok.fountains[0].fountain_id,
+ "test"));
+}
+
+
+static void
+detail_cb (void *cls,
+ const struct TALER_MERCHANT_GetPrivateFountainResponse *r)
+{
+ struct Expected *e = cls;
+
+ e->calls++;
+ GNUNET_assert (e->malformed ? (0 == r->hr.http_status &&
+ TALER_EC_GENERIC_REPLY_MALFORMED == r->hr.ec)
+ : (MHD_HTTP_OK == r->hr.http_status &&
+ e->count == r->details.ok.grants_len));
+ if ( (! e->malformed) && (0 != e->count) )
+ GNUNET_assert (0 == strcmp (r->details.ok.grants[0].token_family_slug,
+ "test"));
+}
+
+
+static void
+check (json_t *grants,
+ unsigned int mode,
+ bool malformed)
+{
+ struct Expected expected = {
+ .count = json_array_size (grants),
+ .malformed = malformed
+ };
+ json_t *reply = json_pack ("{s:{s:i},s:O,s:O,s:s}",
+ "poll_freq", "d_us", 1000000,
+ "grants", grants, "fountains", grants,
+ "description", "Test");
+
+ if (1 == mode)
+ {
+ struct TALER_MERCHANT_PostFountainWithdrawHandle h = {
+ .cb = &withdraw_cb, .cb_cls = &expected
+ };
+ struct TALER_MERCHANT_PostFountainWithdrawResponse r = {
+ .hr = {.http_status = MHD_HTTP_OK, .reply = reply}
+ };
+
+ withdraw_handle_ok (&h, &r);
+ }
+ else if (0 == mode)
+ {
+ struct TALER_MERCHANT_GetFountainInfoHandle h = {
+ .cb = &info_cb, .cb_cls = &expected
+ };
+ struct TALER_MERCHANT_GetFountainInfoResponse r = {
+ .hr = {.http_status = MHD_HTTP_OK, .reply = reply}
+ };
+
+ info_handle_ok (&h, &r);
+ }
+ else if (2 == mode)
+ {
+ struct TALER_MERCHANT_GetPrivateFountainsHandle h = {
+ .cb = &list_cb, .cb_cls = &expected
+ };
+ struct TALER_MERCHANT_GetPrivateFountainsResponse r = {
+ .hr = {.http_status = MHD_HTTP_OK, .reply = reply}
+ };
+
+ list_handle_ok (&h, &r);
+ }
+ else
+ {
+ struct TALER_MERCHANT_GetPrivateFountainHandle h = {
+ .cb = &detail_cb, .cb_cls = &expected
+ };
+ struct TALER_MERCHANT_GetPrivateFountainResponse r = {
+ .hr = {.http_status = MHD_HTTP_OK, .reply = reply}
+ };
+
+ detail_handle_ok (&h, &r);
+ }
+ GNUNET_assert (1 == expected.calls);
+ json_decref (reply);
+}
+
+
+/**
+ * Exercise each nested array through the real fountain-info parser. Use a
+ * preceding valid grant as well, so every failure tests partial cleanup.
+ */
+static void
+check_nested_arrays (json_t *info)
+{
+ const char *fields[] = { "keys", "expected_domains", "trusted_domains" };
+ const unsigned int limits[] = {
+ TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS,
+ TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS,
+ TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS
+ };
+ /* 10 MB of null keys, or 30 MB of null domains, fits the HTTP limit but
+ previously triggered a 48 MB allocation on a 64-bit client. */
+ const unsigned int hostile_counts[] = { 2000000, 6000000, 6000000 };
+ struct TALER_TokenIssuePrivateKey priv;
+ struct TALER_TokenIssuePublicKey pub;
+ struct GNUNET_TIME_Timestamp start = {
+ .abs_time.abs_value_us = 1000000
+ };
+ struct GNUNET_TIME_Timestamp end = {
+ .abs_time.abs_value_us = 2000000
+ };
+ json_t *key;
+ json_t *domain = json_string ("merchant.example");
+
+ GNUNET_CRYPTO_blind_sign_keys_create (&priv.private_key,
+ &pub.public_key,
+ GNUNET_CRYPTO_BSA_CS);
+ key = GNUNET_JSON_PACK (
+ TALER_JSON_pack_token_pub (NULL, &pub),
+ GNUNET_JSON_pack_timestamp ("signature_validity_start", start),
+ GNUNET_JSON_pack_timestamp ("signature_validity_end", end));
+ GNUNET_CRYPTO_blind_sign_priv_decref (priv.private_key);
+ GNUNET_CRYPTO_blind_sign_pub_decref (pub.public_key);
+ for (unsigned int mode = 0; mode < 3; mode++)
+ {
+ json_t *grant = json_deep_copy (info);
+ json_t *family = json_object_get (grant, "token_family");
+ json_t *details = json_object_get (family, "details");
+ json_t *array = json_array ();
+ json_t *grants = json_array ();
+ json_t *valid = (0 == mode) ? key : domain;
+
+ if (2 == mode)
+ {
+ GNUNET_assert (0 == json_object_set_new (
+ details, "class", json_string ("subscription")));
+ GNUNET_assert (0 == json_object_del (details, "expected_domains"));
+ }
+ GNUNET_assert (0 == json_object_set ((0 == mode) ? family : details,
+ fields[mode], array));
+ GNUNET_assert (0 == json_array_append (grants, info));
+ GNUNET_assert (0 == json_array_append (grants, grant));
+ check (grants, 0, false);
+ GNUNET_assert (0 == json_array_append (array, valid));
+ check (grants, 0, false);
+ GNUNET_assert (0 == json_array_append_new (array, json_null ()));
+ check (grants, 0, true);
+ json_array_clear (array);
+ for (unsigned int i = 0; i < limits[mode]; i++)
+ GNUNET_assert (0 == json_array_append (array, valid));
+ check (grants, 0, false);
+ GNUNET_assert (0 == json_array_append (array, valid));
+ check (grants, 0, true);
+ json_array_clear (array);
+ for (unsigned int i = 0; i < hostile_counts[mode]; i++)
+ GNUNET_assert (0 == json_array_append_new (array, json_null ()));
+ check (grants, 0, true);
+ json_decref (grants);
+ json_decref (grant);
+ json_decref (array);
+ }
+ json_decref (key);
+ json_decref (domain);
+}
+
+
+int
+main (void)
+{
+ struct rlimit limit;
+ json_t *grants = json_array ();
+ json_t *info = json_loads (
+ "{\"token_family_slug\":\"test\",\"tokens_per_period_limit\":1,"
+ "\"tokens_per_period_stash\":1,\"key_window_size\":0,"
+ "\"token_family\":{\"name\":\"Test\",\"description\":\"Test\","
+ "\"keys\":[],\"critical\":false,"
+ "\"details\":{\"class\":\"discount\",\"expected_domains\":[]}}}",
+ 0, NULL);
+ struct TALER_TokenIssuePublicKeyHashP hash = {0};
+ json_t *withdraw = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("token_family_slug", "test"),
+ GNUNET_JSON_pack_data_auto ("h_issue", &hash),
+ GNUNET_JSON_pack_array_steal ("token_sigs", json_array ()));
+
+ json_t *list = json_pack ("{s:s,s:s}",
+ "fountain_id", "test", "description", "Test");
+ json_t *valid_grants[] = { info, withdraw, list, info };
+ unsigned int large_counts[] = { 100000, 100000, 600000, 300000 };
+
+ GNUNET_assert (0 == getrlimit (RLIMIT_STACK, &limit));
+ limit.rlim_cur = GNUNET_MIN (limit.rlim_cur, 8 * 1024 * 1024);
+ GNUNET_assert (0 == setrlimit (RLIMIT_STACK, &limit));
+ GNUNET_assert (NULL != info);
+ for (unsigned int mode = 0; mode < 4; mode++)
+ {
+ json_t *valid = valid_grants[mode];
+
+ json_array_clear (grants);
+ check (grants, mode, false);
+ GNUNET_assert (0 == json_array_append (grants, valid));
+ check (grants, mode, false);
+ /* Failure after a parsed grant must release partially parsed data. */
+ GNUNET_assert (0 == json_array_append_new (
+ grants,
+ json_pack ("{s:[]}", "token_sigs")));
+ check (grants, mode, true);
+ json_array_clear (grants);
+ for (unsigned int i = 0;
+ i < TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS; i++)
+ GNUNET_assert (0 == json_array_append (grants, valid));
+ check (grants, mode, false);
+ GNUNET_assert (0 == json_array_append (grants, valid));
+ check (grants, mode, 2 != mode);
+ json_array_clear (grants);
+ for (unsigned int i = 0; i < large_counts[mode]; i++)
+ GNUNET_assert (0 == json_array_append_new (grants, json_null ()));
+ check (grants, mode, true);
+ }
+ /* A list is not capped at the grant limit, but still has a byte budget. */
+ json_array_clear (grants);
+ for (size_t i = 0;
+ i < GNUNET_MAX_MALLOC_CHECKED / sizeof (struct TALER_MERCHANT_FountainEntry);
+ i++)
+ GNUNET_assert (0 == json_array_append (grants, list));
+ check (grants, 2, true);
+ /* A small grant array must not hide an excessive signature total. */
+ json_array_clear (grants);
+ for (unsigned int i = 0; i < 2; i++)
+ {
+ json_t *grant = json_deep_copy (withdraw);
+ json_t *sigs = json_object_get (grant, "token_sigs");
+
+ for (unsigned int j = 0;
+ j <= TALER_MERCHANT_MAX_FOUNTAIN_RESPONSE_ITEMS / 2; j++)
+ GNUNET_assert (0 == json_array_append_new (sigs, json_object ()));
+ GNUNET_assert (0 == json_array_append_new (grants, grant));
+ }
+ check (grants, true, true);
+ json_decref (grants);
+ check_nested_arrays (info);
+ json_decref (info);
+ json_decref (withdraw);
+ json_decref (list);
+ return 0;
+}
diff --git a/src/testing/meson.build b/src/testing/meson.build
@@ -2,6 +2,7 @@
check_SCRIPTS = [
+ 'test_merchant_fountains',
'test_merchant_instance_auth',
'test_merchant_instance_creation',
'test_merchant_instance_response',
@@ -91,6 +92,8 @@ libtalermerchanttesting_la_SOURCES = [
'testing_api_cmd_post_transfers.c',
'testing_api_cmd_post_templates.c',
'testing_api_cmd_post_units.c',
+ 'testing_api_cmd_post_fountains.c',
+ 'testing_api_cmd_fountain_withdraw.c',
'testing_api_cmd_post_tokenfamilies.c',
'testing_api_cmd_post_using_templates.c',
'testing_api_cmd_post_webhooks.c',
diff --git a/src/testing/test_merchant_api.c b/src/testing/test_merchant_api.c
@@ -309,6 +309,35 @@ cmd_transfer_to_exchange (const char *label,
/**
+ * Grants of the fountain used in the token tests (DD 98): the
+ * "subscription-1" family may be withdrawn from, at most 3 tokens
+ * per issue-key validity period, for the current and the next slot.
+ */
+static const struct TALER_MERCHANT_FountainGrant fountain_grants[] = {
+ {
+ .token_family_slug = "subscription-1",
+ .tokens_per_period_limit = 3,
+ .tokens_per_period_stash = 2,
+ .key_window_size = 1
+ }
+};
+
+
+/**
+ * Grants referring to a token family that the fountain does not
+ * grant; used to check that withdrawing from it is refused.
+ */
+static const struct TALER_MERCHANT_FountainGrant fountain_grants_other[] = {
+ {
+ .token_family_slug = "subscription-upcoming",
+ .tokens_per_period_limit = 1,
+ .tokens_per_period_stash = 1,
+ .key_window_size = 0
+ }
+};
+
+
+/**
* Main function that will tell the interpreter what commands to
* run.
*
@@ -3510,6 +3539,164 @@ run (void *cls,
NULL,
0,
"pay-order-with-output"),
+ /* Token fountains (DD 98) */
+ TALER_TESTING_cmd_merchant_post_fountains (
+ "create-fountain",
+ merchant_url,
+ MHD_HTTP_OK,
+ "campaign-reference-1",
+ GNUNET_TIME_UNIT_HOURS,
+ 1,
+ fountain_grants),
+ TALER_TESTING_cmd_merchant_post_fountains (
+ "create-fountain-unknown-family",
+ merchant_url,
+ MHD_HTTP_NOT_FOUND,
+ "campaign-with-unknown-family",
+ GNUNET_TIME_UNIT_HOURS,
+ 1,
+ &(const struct TALER_MERCHANT_FountainGrant) {
+ .token_family_slug = "no-such-token-family",
+ .tokens_per_period_limit = 1,
+ .tokens_per_period_stash = 1,
+ .key_window_size = 0
+ }),
+ TALER_TESTING_cmd_merchant_post_fountains (
+ "create-fountain-other",
+ merchant_url,
+ MHD_HTTP_OK,
+ "campaign-reference-2",
+ GNUNET_TIME_UNIT_HOURS,
+ 1,
+ fountain_grants_other),
+ /* Withdraw a token from the fountain and unblind+verify it. */
+ TALER_TESTING_cmd_merchant_fountain_withdraw (
+ "fountain-withdraw-token",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-fountain",
+ "subscription-1",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ 1),
+ /* Spend the fountain-issued token in an order: proves that a
+ token withdrawn without an order is accepted at payment. */
+ TALER_TESTING_cmd_merchant_post_orders_choices (
+ "create-order-with-fountain-input",
+ cred.cfg,
+ merchant_url,
+ MHD_HTTP_OK,
+ "subscription-1",
+ "A choice in the contract",
+ NULL,
+ 1,
+ 0,
+ "5-fountain-input",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:0.0"),
+ TALER_TESTING_cmd_merchant_pay_order_choices (
+ "pay-order-with-fountain-input",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-order-with-fountain-input",
+ "",
+ "EUR:0",
+ "EUR:0",
+ NULL,
+ 0,
+ "fountain-withdraw-token"),
+ /* The remaining quota of the period is 2 tokens, so 3 more is
+ one too many and must be refused as a whole. */
+ TALER_TESTING_cmd_merchant_fountain_withdraw (
+ "fountain-withdraw-over-limit",
+ merchant_url,
+ MHD_HTTP_TOO_MANY_REQUESTS,
+ "create-fountain",
+ "subscription-1",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ 3),
+ /* ... and exactly the remaining 2 must still succeed, proving
+ the refused request consumed no quota. */
+ TALER_TESTING_cmd_merchant_fountain_withdraw (
+ "fountain-withdraw-rest-of-period",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-fountain",
+ "subscription-1",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ 2),
+ /* Now the period is exhausted. */
+ TALER_TESTING_cmd_merchant_fountain_withdraw (
+ "fountain-withdraw-exhausted",
+ merchant_url,
+ MHD_HTTP_TOO_MANY_REQUESTS,
+ "create-fountain",
+ "subscription-1",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ 1),
+ /* Exercise discount issuance and spending with the same helpers. */
+ TALER_TESTING_cmd_merchant_post_tokenfamilies (
+ "create-fountain-discount-family",
+ merchant_url,
+ MHD_HTTP_NO_CONTENT,
+ "fountain-discount",
+ "Discount",
+ "A promotional discount.",
+ NULL,
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_YEARS),
+ GNUNET_TIME_UNIT_MONTHS,
+ GNUNET_TIME_UNIT_MONTHS,
+ "discount"),
+ TALER_TESTING_cmd_merchant_post_fountains (
+ "create-discount-fountain",
+ merchant_url,
+ MHD_HTTP_OK,
+ "discount-campaign",
+ GNUNET_TIME_UNIT_HOURS,
+ 1,
+ &(const struct TALER_MERCHANT_FountainGrant) {
+ .token_family_slug = "fountain-discount",
+ .tokens_per_period_limit = 1,
+ .tokens_per_period_stash = 1,
+ .key_window_size = 0
+ }),
+ TALER_TESTING_cmd_merchant_fountain_withdraw (
+ "fountain-withdraw-discount",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-discount-fountain",
+ "fountain-discount",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ 1),
+ TALER_TESTING_cmd_merchant_post_orders_choices (
+ "create-order-with-fountain-discount",
+ cred.cfg,
+ merchant_url,
+ MHD_HTTP_OK,
+ "fountain-discount",
+ "Redeem promotional discount",
+ NULL,
+ 1,
+ 0,
+ "5-fountain-discount",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:0.0"),
+ TALER_TESTING_cmd_merchant_pay_order_choices (
+ "pay-order-with-fountain-discount",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-order-with-fountain-discount",
+ "",
+ "EUR:0",
+ "EUR:0",
+ NULL,
+ 0,
+ "fountain-withdraw-discount"),
+ /* Withdrawing from a family the fountain does not grant is
+ covered by test_merchant_fountains.sh, which can post a
+ withdraw request without first resolving an issue key. */
TALER_TESTING_cmd_end ()
};
diff --git a/src/testing/test_merchant_fountains.sh b/src/testing/test_merchant_fountains.sh
@@ -0,0 +1,879 @@
+#!/usr/bin/env bash
+# This file is part of TALER
+# Copyright (C) 2026 Taler Systems SA
+#
+# TALER is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3, or
+# (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public
+# License along with TALER; see the file COPYING. If not, see
+# <http://www.gnu.org/licenses/>
+#
+
+# Exercises the token fountain endpoints (DD 98): private CRUD,
+# GET /fountain/info, withdrawal validation and replay after family deletion.
+# Unblinding and spending withdrawn tokens are exercised by the C harness.
+
+# Cleanup to run whenever we exit
+function my_cleanup()
+{
+ for n in $(jobs -p)
+ do
+ kill "$n" 2> /dev/null || true
+ done
+ wait
+ if [ -n "${LAST_RESPONSE+x}" ]
+ then
+ rm -f "${LAST_RESPONSE}"
+ fi
+}
+
+. setup.sh
+
+setup -c test_template.conf -m
+CONF="test_template.conf.edited"
+LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX)
+MERCHANT_URL="http://localhost:9966"
+
+echo -n "Configuring 'admin' instance ..." >&2
+
+STATUS=$(curl -H "Content-Type: application/json" -X POST \
+ "$MERCHANT_URL/management/instances" \
+ -d '{"auth":{"method":"token","password":"new_pw"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 3600000000},"default_pay_delay":{"d_us": 3600000000}}' \
+ -w "%{http_code}" \
+ -s \
+ -o /dev/null)
+
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204, instance created. got: $STATUS" >&2
+fi
+
+BASIC_AUTH=$(echo -n admin:new_pw | base64)
+
+STATUS=$(curl -H "Content-Type: application/json" -X POST \
+ -H "Authorization: Basic $BASIC_AUTH" \
+ "$MERCHANT_URL/private/token" \
+ -d '{"scope":"spa"}' \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS"
+fi
+
+BEARER_TOKEN=$(jq -e -r .access_token < "$LAST_RESPONSE")
+
+echo " OK" >&2
+
+#
+# CREATE TOKEN FAMILIES FOR THE FOUNTAIN GRANTS
+#
+echo -n "Creating discount token family..." >&2
+VALID_AFTER="{\"t_s\": $(date +%s)}" # now
+VALID_BEFORE="{\"t_s\": $(date +%s -d "+300 days")}" # 300 days from now
+DURATION="{\"d_us\": $(expr 3 \* 60 \* 1000000)}" # 3 minutes
+GRANULARITY="{\"d_us\": $(expr 60 \* 1000000)}" # 1 minute
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
+ -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"kind\": \"discount\", \"slug\":\"test-discount\", \"name\": \"Test discount\", \"description\": \"Less money\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '204' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Creating subscription token family..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
+ -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"kind\": \"subscription\", \"slug\":\"test-subscription\", \"name\": \"Test subscription\", \"description\": \"Monthly pass\", \"valid_after\": $VALID_AFTER, \"valid_before\": $VALID_BEFORE, \"duration\": $DURATION, \"validity_granularity\": $GRANULARITY}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '204' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# CREATE A FOUNTAIN
+#
+echo -n "Creating fountain..." >&2
+POLL_FREQ="{\"d_us\": 3600000000}" # 1 hour
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\": \"campaign-reference-42\", \"poll_freq\": $POLL_FREQ, \"grants\": []}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+FOUNTAIN_ID=$(jq -e -r .fountain_id < "$LAST_RESPONSE")
+FOUNTAIN_SECRET=$(jq -e -r .fountain_secret < "$LAST_RESPONSE")
+echo "Ok (id: $FOUNTAIN_ID)" >&2
+
+echo -n "Empty fountain remains accessible while grants are added and cleared..." >&2
+FOUNTAIN_GRANTS='[{"token_family_slug":"test-discount","tokens_per_period_limit":5,"tokens_per_period_stash":2,"key_window_size":2},{"token_family_slug":"test-subscription","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":1}]'
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FOUNTAIN_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || ! jq -e '.grants == []' "$LAST_RESPONSE" > /dev/null
+then
+ exit_fail "Expected accessible empty fountain after creation"
+fi
+for EXPECTED_GRANTS in '[]' "$FOUNTAIN_GRANTS" '[]' "$FOUNTAIN_GRANTS"
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"grants\": $EXPECTED_GRANTS}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "204" ]
+ then
+ exit_fail "Expected 204 updating fountain grants, got $STATUS"
+ fi
+ STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FOUNTAIN_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ] || ! jq -e --argjson expected "$EXPECTED_GRANTS" \
+ '(.grants | map(.token_family_slug) | sort) == ($expected | map(.token_family_slug) | sort)' \
+ "$LAST_RESPONSE" > /dev/null
+ then
+ exit_fail "Fountain info did not reflect the updated grants"
+ fi
+done
+echo "Ok" >&2
+
+echo -n "Patching poll frequency and preserving it when omitted..." >&2
+for PATCH_BODY in '{"poll_freq":{"d_us":60000000}}' '{"description":"campaign-reference-42"}' '{}'
+do
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "$PATCH_BODY" -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "204" ]
+ then
+ exit_fail "Expected 204 for fountain PATCH, got $STATUS"
+ fi
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ] || ! jq -e '.poll_freq.d_us == 60000000' "$LAST_RESPONSE" > /dev/null
+ then
+ exit_fail "PATCH did not set or preserve poll_freq"
+ fi
+done
+# Restore the original polling frequency for the remaining CRUD checks.
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"poll_freq\": $POLL_FREQ}" -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 restoring poll_freq, got $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Creating fountain with unknown token family fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"no-such-family\", \"tokens_per_period_limit\": 5, \"tokens_per_period_stash\": 2, \"key_window_size\": 2}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "404" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '404' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Creating fountain with stash above limit fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\": \"broken\", \"poll_freq\": $POLL_FREQ, \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 2, \"tokens_per_period_stash\": 5, \"key_window_size\": 2}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "400" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '400' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# LIST AND INSPECT
+#
+echo -n "Listing fountains..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+COUNT=$(jq -e '.fountains | length' < "$LAST_RESPONSE")
+if [ "$COUNT" != "1" ]
+then
+ exit_fail "Expected 1 fountain in list, got $COUNT"
+fi
+echo "Ok" >&2
+
+echo -n "Inspecting fountain..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+DESC=$(jq -e -r .description < "$LAST_RESPONSE")
+if [ "$DESC" != "campaign-reference-42" ]
+then
+ exit_fail "Expected description 'campaign-reference-42', got $DESC"
+fi
+GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
+if [ "$GRANTS" != "2" ]
+then
+ exit_fail "Expected 2 grants, got $GRANTS"
+fi
+if jq -e '.fountain_secret' < "$LAST_RESPONSE" > /dev/null 2>&1
+then
+ exit_fail "Fountain secret must never be returned by the private API"
+fi
+echo "Ok" >&2
+
+echo -n "Inspecting unknown fountain fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "404" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '404' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# WALLET INFO ENDPOINT
+#
+echo -n "Fetching fountain info with bearer secret..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FOUNTAIN_SECRET" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
+if [ "$GRANTS" != "2" ]
+then
+ exit_fail "Expected 2 grants in info, got $GRANTS"
+fi
+# At least one issue key must be offered, and at most one per slot of
+# the grant's key window (slots whose validity period is covered by an
+# already-listed key share that key, so fewer is legitimate).
+DISCOUNT_KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE")
+if [ "$DISCOUNT_KEYS" -lt 1 ] || [ "$DISCOUNT_KEYS" -gt 3 ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 1..3 issue keys for test-discount, got $DISCOUNT_KEYS"
+fi
+KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-subscription") | .token_family.keys | length' < "$LAST_RESPONSE")
+if [ "$KEYS" -lt 1 ] || [ "$KEYS" -gt 2 ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 1..2 issue keys for test-subscription, got $KEYS"
+fi
+# Issue keys must be distinct.
+UNIQUE_KEYS=$(jq -e '[.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys[]] | unique | length' < "$LAST_RESPONSE")
+if [ "$UNIQUE_KEYS" != "$DISCOUNT_KEYS" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Duplicate issue keys advertised: $DISCOUNT_KEYS listed, $UNIQUE_KEYS distinct"
+fi
+echo "Ok" >&2
+
+echo -n "Fetching fountain info again is idempotent..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FOUNTAIN_SECRET" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+# Re-polling must be idempotent: no additional keys may be minted.
+KEYS=$(jq -e '.grants[] | select(.token_family_slug == "test-discount") | .token_family.keys | length' < "$LAST_RESPONSE")
+if [ "$KEYS" != "$DISCOUNT_KEYS" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Re-poll changed issue key count: was $DISCOUNT_KEYS, now $KEYS"
+fi
+echo "Ok" >&2
+
+echo -n "Fetching fountain info with bad secret fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer 0000000000000000000000000000000000000000000000000000" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "401" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '401' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Fetching fountain info without secret fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "401" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '401' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# WITHDRAW VALIDATION ERRORS
+#
+echo -n "Withdrawing with bad secret fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"0000000000000000000000000000000000000000000000000000\", \"grants\": []}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "401" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '401' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Withdrawing for ungranted family fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"no-such-family\", \"envelopes\": []}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "409" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '409' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Withdrawing outside the key window fails..." >&2
+FUTURE="{\"t_s\": $(date +%s -d "+200 days")}"
+# A real envelope is required: an empty "envelopes" array is rejected as a
+# malformed request before the key window is ever resolved.
+ENVELOPE="{\"cipher\": \"RSA\", \"rsa_blinded_planchet\": \"04\"}"
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"valid_at\": $FUTURE, \"envelopes\": [$ENVELOPE]}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+# Check the error code as well: an unknown grant also answers 409, and would
+# otherwise hide a request that never reached the key window check.
+if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '409' TOKEN_KEY_SLOT_OUTSIDE_WINDOW. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Withdrawing without envelopes fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": []}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "400" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '400' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# PATCH
+#
+echo -n "Wrong envelope cipher is a bad request and does not consume quota..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d '{"description":"cipher-validation","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-discount","tokens_per_period_limit":1,"tokens_per_period_stash":1,"key_window_size":0}]}' \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 creating cipher-validation fountain, got $STATUS"
+fi
+CIPHER_FOUNTAIN_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
+CIPHER_FOUNTAIN_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
+# The family uses RSA. These zero-valued CS fields are structurally valid,
+# so rejection must come from matching the envelope against the issue key.
+CS_ZERO=0000000000000000000000000000000000000000000000000000
+CS_ENVELOPE=$(jq -nc --arg z "$CS_ZERO" \
+ '{cipher:"CS",cs_nonce:$z,cs_blinded_c0:$z,cs_blinded_c1:$z}')
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
+ -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$CS_ENVELOPE]}]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "400" ] || ! jq -e '.code == 26' "$LAST_RESPONSE" > /dev/null
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 400 GENERIC_PARAMETER_MALFORMED for wrong cipher, got $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
+ -d "{\"fountain_secret\":\"$CIPHER_FOUNTAIN_SECRET\",\"grants\":[{\"token_family_slug\":\"test-discount\",\"envelopes\":[$ENVELOPE]}]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Wrong-cipher request consumed quota: valid withdrawal returned $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$CIPHER_FOUNTAIN_ID" \
+ -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 deleting cipher-validation fountain, got $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Fountain quotas are restricted to nonnegative INT8 values..." >&2
+# Keep the large integer literals as text: passing them through jq arithmetic
+# can round INT64_MAX and accidentally test a different input.
+for QUOTAS in \
+ '"tokens_per_period_limit":0,"tokens_per_period_stash":0' \
+ '"tokens_per_period_limit":9223372036854775807,"tokens_per_period_stash":9223372036854775807'
+do
+ QUOTA_GRANT="{\"token_family_slug\":\"test-discount\",$QUOTAS,\"key_window_size\":0}"
+ STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$QUOTA_GRANT]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ]
+ then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 200 for valid quota boundary, got $STATUS"
+ fi
+ QUOTA_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"grants\":[$QUOTA_GRANT]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "204" ]
+ then
+ exit_fail "Expected 204 patching valid quota boundary, got $STATUS"
+ fi
+ for BAD_QUOTAS in \
+ '"tokens_per_period_limit":-1,"tokens_per_period_stash":0' \
+ '"tokens_per_period_limit":0,"tokens_per_period_stash":-1' \
+ '"tokens_per_period_limit":-1,"tokens_per_period_stash":-1' \
+ '"tokens_per_period_limit":-9223372036854775808,"tokens_per_period_stash":0' \
+ '"tokens_per_period_limit":9223372036854775808,"tokens_per_period_stash":0'
+ do
+ BAD_GRANT="{\"token_family_slug\":\"test-discount\",$BAD_QUOTAS,\"key_window_size\":0}"
+ for METHOD in POST PATCH
+ do
+ QUOTA_URL="$MERCHANT_URL/private/fountains"
+ if [ "$METHOD" = PATCH ]
+ then
+ QUOTA_URL="$QUOTA_URL/$QUOTA_ID"
+ fi
+ STATUS=$(curl "$QUOTA_URL" -X "$METHOD" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\":\"quota-bounds\",\"poll_freq\":{\"d_us\":60000000},\"grants\":[$BAD_GRANT]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "400" ]
+ then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 400 for $METHOD with $BAD_QUOTAS, got $STATUS"
+ fi
+ done
+ done
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$QUOTA_ID" \
+ -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "204" ]
+ then
+ exit_fail "Expected 204 deleting quota test fountain, got $STATUS"
+ fi
+done
+echo "Ok" >&2
+
+echo -n "Offset families advertise covering keys and allow withdrawal..." >&2
+OFFSET_NOW=$(date +%s)
+OFFSET_START=$((OFFSET_NOW - 172800))
+OFFSET_END=$((OFFSET_NOW + 172800))
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"slug\":\"test-offset\",\"name\":\"Offset family\",\"description\":\"Offset coverage\",\"kind\":\"discount\",\"valid_after\":{\"t_s\":$OFFSET_START},\"valid_before\":{\"t_s\":$OFFSET_END},\"duration\":{\"d_us\":7140000000},\"validity_granularity\":{\"d_us\":3600000000},\"start_offset\":{\"d_us\":3540000000}}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 204 creating offset family, got $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d '{"description":"offset-coverage","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-offset","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":2}]}' \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 creating offset fountain, got $STATUS"
+fi
+OFFSET_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
+OFFSET_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $OFFSET_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || ! jq -e --argjson now "$OFFSET_NOW" \
+ '.grants[0].token_family.keys as $keys |
+ ($keys | length) == 3 and
+ $keys[0].signature_validity_start.t_s <= $now and
+ $keys[0].signature_validity_end.t_s >= $now and
+ all(range(1;3); . as $i |
+ $keys[$i].signature_validity_start.t_s <= $keys[$i-1].signature_validity_end.t_s + 1 and
+ $keys[$i].signature_validity_end.t_s > $keys[$i-1].signature_validity_end.t_s)' \
+ "$LAST_RESPONSE" > /dev/null
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Offset family did not advertise a covering key window"
+fi
+OFFSET_SLOTS=$(jq -r '.grants[0].token_family.keys[].signature_validity_start.t_s' "$LAST_RESPONSE")
+for SLOT in $OFFSET_SLOTS
+do
+ STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST \
+ -d "{\"fountain_secret\":\"$OFFSET_SECRET\",\"grants\":[{\"token_family_slug\":\"test-offset\",\"valid_at\":{\"t_s\":$SLOT},\"envelopes\":[$ENVELOPE]}]}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ]
+ then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 200 withdrawing advertised offset key, got $STATUS"
+ fi
+done
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$OFFSET_ID" \
+ -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 deleting offset fountain, got $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Future keys are bounded by durations from now, not the family start..." >&2
+# One-hour duration and one-minute granularity distinguish which interval
+# bounds prefetching. Start halfway through the first future duration.
+FIRST_START=$(date +%s -d "+30 minutes")
+FAMILY_REQUEST=$(jq -nc --argjson start "$FIRST_START" \
+ '{slug:"test-future",name:"Future promotion",description:"Starts soon",kind:"discount",valid_after:{t_s:$start},duration:{d_us:3600000000},validity_granularity:{d_us:60000000}}')
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 creating future token family, got $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/private/fountains" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d '{"description":"future-start","poll_freq":{"d_us":60000000},"grants":[{"token_family_slug":"test-future","tokens_per_period_limit":3,"tokens_per_period_stash":1,"key_window_size":0}]}' \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 creating future fountain, got $STATUS"
+fi
+FUTURE_SECRET=$(jq -er .fountain_secret "$LAST_RESPONSE")
+FUTURE_ID=$(jq -er .fountain_id "$LAST_RESPONSE")
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FUTURE_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null
+then
+ exit_fail "Zero-size window advertised a future key"
+fi
+for VALID_AT in null "$(date +%s)" "$FIRST_START"
+do
+ WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$VALID_AT" \
+ --argjson envelope "$ENVELOPE" \
+ '{fountain_secret:$secret,grants:[({token_family_slug:"test-future",envelopes:[$envelope]} + (if $at == null then {} else {valid_at:{t_s:$at}} end))]}')
+ STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
+ then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected 409 for a future family with window 0, got $STATUS"
+ fi
+done
+for WINDOW in 1 2
+do
+ PATCH_REQUEST=$(jq -nc --argjson window "$WINDOW" \
+ '{grants:[{token_family_slug:"test-future",tokens_per_period_limit:3,tokens_per_period_stash:1,key_window_size:$window}]}')
+ STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "204" ]
+ then
+ exit_fail "Expected 204 changing future window, got $STATUS"
+ fi
+ STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FUTURE_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ HORIZON=$(($(date +%s) + WINDOW * 3600))
+ if [ "$STATUS" != "200" ] || ! jq -e --argjson count "$WINDOW" --argjson horizon "$HORIZON" \
+ '(.grants[0].token_family.keys | length) == $count and
+ all(.grants[0].token_family.keys[]; .signature_validity_start.t_s <= $horizon and .signature_validity_end.t_s <= ($horizon + 3600))' \
+ "$LAST_RESPONSE" > /dev/null
+ then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Future window did not respect its duration-based horizon"
+ fi
+ SLOT_START=$(jq -er '.grants[0].token_family.keys[-1].signature_validity_start.t_s' "$LAST_RESPONSE")
+ WITHDRAW_REQUEST=$(jq -nc --arg secret "$FUTURE_SECRET" --argjson at "$SLOT_START" \
+ --argjson envelope "$ENVELOPE" \
+ '{fountain_secret:$secret,grants:[{token_family_slug:"test-future",valid_at:{t_s:$at},envelopes:[$envelope]}]}')
+ STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "200" ]
+ then
+ exit_fail "Expected 200 for advertised future key, got $STATUS"
+ fi
+ # The next key would start outside the horizon, even though this
+ # family began later than now. Neither info nor withdraw may offer it.
+ WITHDRAW_REQUEST=$(jq --argjson at "$((SLOT_START + 3601))" \
+ '.grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST")
+ STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+ if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
+ then
+ exit_fail "Expected 409 beyond the duration-based horizon, got $STATUS"
+ fi
+done
+# Even with a nonzero window, a family starting four durations ahead
+# must not shift the horizon to its own first key.
+FAR_START=$(date +%s -d "+4 hours")
+FAMILY_REQUEST=$(jq --argjson start "$FAR_START" \
+ '.slug = "test-far-future" | .valid_after = {t_s:$start}' <<< "$FAMILY_REQUEST")
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies" \
+ -X POST -H "Authorization: Bearer $BEARER_TOKEN" -d "$FAMILY_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 creating far-future family, got $STATUS"
+fi
+PATCH_REQUEST=$(jq '.grants[0].token_family_slug = "test-far-future"' <<< "$PATCH_REQUEST")
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FUTURE_ID" \
+ -X PATCH -H "Authorization: Bearer $BEARER_TOKEN" -d "$PATCH_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 changing granted family, got $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FUTURE_SECRET" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || ! jq -e '.grants[0].token_family.keys == []' "$LAST_RESPONSE" > /dev/null
+then
+ exit_fail "Far-future family shifted the advertised horizon"
+fi
+WITHDRAW_REQUEST=$(jq --argjson at "$FAR_START" \
+ '.grants[0].token_family_slug = "test-far-future" | .grants[0].valid_at = {t_s:$at}' <<< "$WITHDRAW_REQUEST")
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" -X POST -d "$WITHDRAW_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "409" ] || ! jq -e '.code == 2951' "$LAST_RESPONSE" > /dev/null
+then
+ exit_fail "Expected 409 for far-future family, got $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Saving a two-family withdrawal for replay..." >&2
+REPLAY_REQUEST="{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"envelopes\": [$ENVELOPE]}, {\"token_family_slug\": \"test-subscription\", \"envelopes\": [$ENVELOPE]}]}"
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST -d "$REPLAY_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || ! jq -e '.grants | length == 2' "$LAST_RESPONSE" > /dev/null
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected a successful two-family withdrawal, got $STATUS"
+fi
+REPLAY_RESPONSE=$(jq -cS . "$LAST_RESPONSE")
+echo "Ok" >&2
+
+echo -n "Patching fountain (drop subscription grant)..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X PATCH \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\": \"campaign-reference-43\", \"grants\": [{\"token_family_slug\": \"test-discount\", \"tokens_per_period_limit\": 7, \"tokens_per_period_stash\": 3, \"key_window_size\": 2}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '204' response. Got instead $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '200 OK' response. Got instead $STATUS"
+fi
+DESC=$(jq -e -r .description < "$LAST_RESPONSE")
+if [ "$DESC" != "campaign-reference-43" ]
+then
+ exit_fail "Expected patched description, got $DESC"
+fi
+GRANTS=$(jq -e '.grants | length' < "$LAST_RESPONSE")
+if [ "$GRANTS" != "1" ]
+then
+ exit_fail "Expected 1 grant after patch, got $GRANTS"
+fi
+echo "Ok" >&2
+
+echo -n "Withdrawing for dropped grant fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": [{\"token_family_slug\": \"test-subscription\", \"envelopes\": []}]}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "409" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '409' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Patching unknown fountain fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/does-not-exist" \
+ -X PATCH \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -d "{\"description\": \"nope\"}" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "404" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '404' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+#
+# DELETE
+#
+echo -n "Replaying the complete response after token family deletion..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/tokenfamilies/test-subscription" \
+ -X DELETE -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected token family deletion to return 204, got $STATUS"
+fi
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST -d "$REPLAY_REQUEST" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ] || [ "$(jq -cS . "$LAST_RESPONSE")" != "$REPLAY_RESPONSE" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Family deletion changed the original withdrawal response ($STATUS)"
+fi
+echo "Ok" >&2
+
+echo -n "Deleting fountain..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X DELETE \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "204" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '204' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Fountain info after deletion fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/info" \
+ -H "Authorization: Bearer $FOUNTAIN_SECRET" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "401" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '401' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Withdrawal after deletion fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/fountain/withdraw" \
+ -X POST \
+ -d "{\"fountain_secret\": \"$FOUNTAIN_SECRET\", \"grants\": []}" \
+ -w "%{http_code}" -s -o "$LAST_RESPONSE")
+if [ "$STATUS" != "401" ]
+then
+ exit_fail "Expected 401 withdrawing from deleted fountain, got $STATUS"
+fi
+echo "Ok" >&2
+
+echo -n "Deleting unknown fountain fails..." >&2
+STATUS=$(curl "$MERCHANT_URL/private/fountains/$FOUNTAIN_ID" \
+ -X DELETE \
+ -H "Authorization: Bearer $BEARER_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "404" ]
+then
+ cat "$LAST_RESPONSE" >&2
+ exit_fail "Expected '404' response. Got instead $STATUS"
+fi
+echo "Ok" >&2
+
+echo "Test PASSED" >&2
+exit 0
diff --git a/src/testing/testing_api_cmd_fountain_withdraw.c b/src/testing/testing_api_cmd_fountain_withdraw.c
@@ -0,0 +1,616 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as
+ published by the Free Software Foundation; either version 3, or
+ (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/testing/testing_api_cmd_fountain_withdraw.c
+ * @brief command simulating a wallet withdrawing tokens from a
+ * fountain (DD 98): fetches GET /fountain/info, prepares
+ * blinded envelopes, runs POST /fountain/withdraw and
+ * unblinds and verifies the resulting tokens
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+struct FountainWithdrawState;
+#define TALER_MERCHANT_GET_FOUNTAIN_INFO_RESULT_CLOSURE \
+ struct FountainWithdrawState
+#define TALER_MERCHANT_POST_FOUNTAIN_WITHDRAW_RESULT_CLOSURE \
+ struct FountainWithdrawState
+#include <gnunet/gnunet_time_lib.h>
+#include <taler/taler_exchange_service.h>
+#include <taler/taler_testing_lib.h>
+#include "taler/taler_merchant_service.h"
+#include "taler/taler_merchant_testing_lib.h"
+#include <taler/merchant/get-fountain-info.h>
+#include <taler/merchant/post-fountain-withdraw.h>
+
+
+/**
+ * Client-side state of one token being withdrawn.
+ */
+struct FountainToken
+{
+
+ /**
+ * Master secret used to derive the private key from.
+ */
+ struct TALER_TokenUseMasterSecretP master;
+
+ /**
+ * Private key of the token.
+ */
+ struct TALER_TokenUsePrivateKeyP token_priv;
+
+ /**
+ * Public key of the token.
+ */
+ struct TALER_TokenUsePublicKeyP token_pub;
+
+ /**
+ * Hash of the public key of the token.
+ */
+ struct TALER_TokenUsePublicKeyHashP h_token_pub;
+
+ /**
+ * Blinded public key of the token.
+ */
+ struct TALER_TokenEnvelope envelope;
+
+ /**
+ * Value used to blind the key for the signature.
+ */
+ union GNUNET_CRYPTO_BlindingSecretP blinding_secret;
+
+ /**
+ * Inputs needed from the merchant for blind signing.
+ */
+ struct TALER_TokenUseMerchantValues blinding_inputs;
+
+ /**
+ * Token issue public key.
+ */
+ struct TALER_TokenIssuePublicKey issue_pub;
+
+ /**
+ * Unblinded token issue signature made by the merchant.
+ */
+ struct TALER_TokenIssueSignature issue_sig;
+
+};
+
+
+/**
+ * State of a fountain withdraw CMD.
+ */
+struct FountainWithdrawState
+{
+
+ /**
+ * Expected status code of the withdraw request.
+ */
+ unsigned int http_status;
+
+ /**
+ * Handle for the "GET /fountain/info" request.
+ */
+ struct TALER_MERCHANT_GetFountainInfoHandle *info_handle;
+
+ /**
+ * Handle for the "POST /fountain/withdraw" request.
+ */
+ struct TALER_MERCHANT_PostFountainWithdrawHandle *withdraw_handle;
+
+ /**
+ * The interpreter state.
+ */
+ struct TALER_TESTING_Interpreter *is;
+
+ /**
+ * Base URL of the merchant serving the request.
+ */
+ const char *merchant_url;
+
+ /**
+ * Label of the command holding the fountain secret.
+ */
+ const char *fountain_reference;
+
+ /**
+ * Slug of the token family to withdraw from.
+ */
+ const char *token_family_slug;
+
+ /**
+ * Desired token validity time; zero for "now".
+ */
+ struct GNUNET_TIME_Timestamp valid_at;
+
+ /**
+ * The fountain's bearer credential (from the referenced command).
+ */
+ const char *fountain_secret;
+
+ /**
+ * Tokens being withdrawn.
+ */
+ struct FountainToken *tokens;
+
+ /**
+ * Number of tokens in @e tokens.
+ */
+ unsigned int num_tokens;
+
+ /**
+ * First successful response, retained to check an identical retry.
+ */
+ json_t *first_reply;
+};
+
+
+/**
+ * Submit the prepared envelopes, including when replaying a request.
+ *
+ * @param state our command state
+ */
+static void
+start_withdraw (struct FountainWithdrawState *state);
+
+
+/**
+ * Callback for the POST /fountain/withdraw operation: unblinds and
+ * verifies the tokens on success.
+ *
+ * @param state our command state
+ * @param fwr response being processed
+ */
+static void
+withdraw_cb (struct FountainWithdrawState *state,
+ const struct TALER_MERCHANT_PostFountainWithdrawResponse *fwr)
+{
+ state->withdraw_handle = NULL;
+ if (state->http_status != fwr->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u (%d) to command %s\n",
+ fwr->hr.http_status,
+ (int) fwr->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (state->is));
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ if (MHD_HTTP_OK == fwr->hr.http_status)
+ {
+ const struct TALER_MERCHANT_FountainWithdrawResult *res;
+
+ if (NULL == state->first_reply)
+ {
+ state->first_reply = json_incref ((json_t *) fwr->hr.reply);
+ start_withdraw (state);
+ return;
+ }
+ if (! json_equal (state->first_reply,
+ fwr->hr.reply))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Fountain retry returned different signatures\n");
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ if (1 != fwr->details.ok.results_len)
+ {
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ res = &fwr->details.ok.results[0];
+ /* The reported issue key must be the one we blinded against;
+ otherwise the signatures cannot verify. */
+ if (0 != GNUNET_memcmp (&res->h_issue.hash,
+ &state->tokens[0].issue_pub.public_key->
+ pub_key_hash))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Merchant signed with issue key %s, but we blinded against %s\n",
+ GNUNET_h2s (&res->h_issue.hash),
+ GNUNET_h2s2 (&state->tokens[0].issue_pub.public_key->
+ pub_key_hash));
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ if (res->num_sigs != state->num_tokens)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Sent %u envelopes but got %u blind signatures\n",
+ state->num_tokens,
+ res->num_sigs);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ for (unsigned int i = 0; i < state->num_tokens; i++)
+ {
+ struct FountainToken *token = &state->tokens[i];
+
+ /* The signatures are in the same order as the envelopes. */
+ if (GNUNET_OK !=
+ TALER_token_issue_sig_unblind (&token->issue_sig,
+ &res->token_sigs[i],
+ &token->blinding_secret,
+ &token->h_token_pub,
+ &token->blinding_inputs,
+ &token->issue_pub))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Failed to unblind token signature\n");
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ if (GNUNET_OK !=
+ TALER_token_issue_verify (&token->token_pub,
+ &token->issue_pub,
+ &token->issue_sig))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unblinded token signature is invalid\n");
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ }
+ }
+ TALER_TESTING_interpreter_next (state->is);
+}
+
+
+static void
+start_withdraw (struct FountainWithdrawState *state)
+{
+ struct TALER_TokenEnvelope envelopes[state->num_tokens];
+ struct TALER_MERCHANT_FountainWithdrawEntry entry = {
+ .token_family_slug = state->token_family_slug,
+ .valid_at = state->valid_at,
+ .num_envelopes = state->num_tokens,
+ .envelopes = envelopes
+ };
+ enum TALER_ErrorCode ec;
+
+ for (unsigned int i = 0; i < state->num_tokens; i++)
+ envelopes[i] = state->tokens[i].envelope;
+ state->withdraw_handle = TALER_MERCHANT_post_fountain_withdraw_create (
+ TALER_TESTING_interpreter_get_context (state->is),
+ state->merchant_url,
+ state->fountain_secret,
+ 1,
+ &entry);
+ ec = TALER_MERCHANT_post_fountain_withdraw_start (
+ state->withdraw_handle,
+ &withdraw_cb,
+ state);
+ GNUNET_assert (TALER_EC_NONE == ec);
+}
+
+
+/**
+ * Callback for the GET /fountain/info operation: selects the issue
+ * key, blinds the token envelopes and starts the withdraw request.
+ *
+ * @param state our command state
+ * @param fir response being processed
+ */
+static void
+info_cb (struct FountainWithdrawState *state,
+ const struct TALER_MERCHANT_GetFountainInfoResponse *fir)
+{
+ const struct TALER_MERCHANT_FountainWalletGrant *grant = NULL;
+ const struct TALER_MERCHANT_ContractTokenFamilyKey *key = NULL;
+ struct GNUNET_TIME_Timestamp valid_at = state->valid_at;
+
+ state->info_handle = NULL;
+ if (MHD_HTTP_OK != fir->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "GET /fountain/info failed with response code %u (%d) in command %s\n",
+ fir->hr.http_status,
+ (int) fir->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (state->is));
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ if (GNUNET_TIME_absolute_is_zero (valid_at.abs_time))
+ valid_at = GNUNET_TIME_timestamp_get ();
+ for (unsigned int i = 0; i < fir->details.ok.grants_len; i++)
+ {
+ if (0 == strcmp (fir->details.ok.grants[i].grant.token_family_slug,
+ state->token_family_slug))
+ {
+ grant = &fir->details.ok.grants[i];
+ break;
+ }
+ }
+ if (NULL == grant)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "No grant for token family %s in fountain info\n",
+ state->token_family_slug);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ /* An explicit advertised start selects that key, even in an overlap.
+ An omitted valid_at selects the first advertised key. */
+ if (GNUNET_TIME_absolute_is_zero (state->valid_at.abs_time))
+ {
+ if (0 != grant->token_family.keys_len)
+ key = &grant->token_family.keys[0];
+ }
+ else
+ for (unsigned int i = 0; i < grant->token_family.keys_len; i++)
+ if (GNUNET_TIME_timestamp_cmp (grant->token_family.keys[i].valid_after,
+ ==,
+ valid_at))
+ {
+ key = &grant->token_family.keys[i];
+ break;
+ }
+ for (unsigned int i = 0; (NULL == key) && (i < grant->token_family.keys_len); i++)
+ {
+ const struct TALER_MERCHANT_ContractTokenFamilyKey *k
+ = &grant->token_family.keys[i];
+
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Fountain info key %u: %s valid %llu - %llu (want %llu)\n",
+ i,
+ GNUNET_h2s (&k->pub.public_key->pub_key_hash),
+ (unsigned long long) k->valid_after.abs_time.abs_value_us,
+ (unsigned long long) k->valid_before.abs_time.abs_value_us,
+ (unsigned long long) valid_at.abs_time.abs_value_us);
+ if ( (GNUNET_TIME_timestamp_cmp (k->valid_after,
+ <=,
+ valid_at)) &&
+ (GNUNET_TIME_timestamp_cmp (valid_at,
+ <=,
+ k->valid_before)) )
+ {
+ key = k;
+ break;
+ }
+ }
+ if (NULL == key)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "No issue key covering the desired validity time in fountain info\n");
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ /* Prepare the blinded envelopes. */
+ for (unsigned int i = 0; i < state->num_tokens; i++)
+ {
+ struct FountainToken *token = &state->tokens[i];
+
+ TALER_token_issue_pub_copy (&token->issue_pub,
+ &key->pub);
+ /* Only RSA is supported for now. */
+ GNUNET_assert (GNUNET_CRYPTO_BSA_RSA ==
+ token->issue_pub.public_key->cipher);
+ TALER_token_blind_input_copy (&token->blinding_inputs,
+ TALER_token_blind_input_rsa_singleton ());
+ TALER_token_use_setup_random (&token->master);
+ TALER_token_use_setup_priv (&token->master,
+ &token->blinding_inputs,
+ &token->token_priv);
+ TALER_token_use_blinding_secret_create (&token->master,
+ &token->blinding_inputs,
+ &token->blinding_secret);
+ GNUNET_CRYPTO_eddsa_key_get_public (
+ &token->token_priv.private_key,
+ &token->token_pub.public_key);
+ GNUNET_CRYPTO_hash (&token->token_pub.public_key,
+ sizeof (struct GNUNET_CRYPTO_EcdsaPublicKey),
+ &token->h_token_pub.hash);
+ token->envelope.blinded_pub =
+ GNUNET_CRYPTO_message_blind_to_sign (
+ token->issue_pub.public_key,
+ &token->blinding_secret,
+ NULL, /* session nonce, only needed for CS */
+ &token->h_token_pub.hash,
+ sizeof (token->h_token_pub.hash),
+ token->blinding_inputs.blinding_inputs);
+ if (NULL == token->envelope.blinded_pub)
+ {
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ }
+ start_withdraw (state);
+}
+
+
+/**
+ * Run the fountain withdraw CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run now.
+ * @param is interpreter state.
+ */
+static void
+fountain_withdraw_run (void *cls,
+ const struct TALER_TESTING_Command *cmd,
+ struct TALER_TESTING_Interpreter *is)
+{
+ struct FountainWithdrawState *state = cls;
+ const struct TALER_TESTING_Command *fountain_cmd;
+
+ state->is = is;
+ fountain_cmd = TALER_TESTING_interpreter_lookup_command (
+ is,
+ state->fountain_reference);
+ if (NULL == fountain_cmd)
+ {
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (is);
+ return;
+ }
+ if (GNUNET_OK !=
+ TALER_TESTING_get_trait_fountain_secret (fountain_cmd,
+ &state->fountain_secret))
+ {
+ GNUNET_break (0);
+ TALER_TESTING_interpreter_fail (is);
+ return;
+ }
+ state->info_handle = TALER_MERCHANT_get_fountain_info_create (
+ TALER_TESTING_interpreter_get_context (is),
+ state->merchant_url,
+ state->fountain_secret);
+ {
+ enum TALER_ErrorCode ec;
+
+ ec = TALER_MERCHANT_get_fountain_info_start (
+ state->info_handle,
+ &info_cb,
+ state);
+ GNUNET_assert (TALER_EC_NONE == ec);
+ }
+}
+
+
+/**
+ * Offers information from the fountain withdraw CMD state to other
+ * commands; in particular the withdrawn tokens can be spent by
+ * referencing this command from a pay command's token reference.
+ *
+ * @param cls closure
+ * @param[out] ret result (could be anything)
+ * @param trait name of the trait
+ * @param index index number of the object to extract.
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+fountain_withdraw_traits (void *cls,
+ const void **ret,
+ const char *trait,
+ unsigned int index)
+{
+ struct FountainWithdrawState *state = cls;
+
+ if (index >= state->num_tokens)
+ return GNUNET_SYSERR;
+ {
+ struct TALER_TESTING_Trait traits[] = {
+ TALER_TESTING_make_trait_token_priv (
+ index,
+ &state->tokens[index].token_priv),
+ TALER_TESTING_make_trait_token_issue_pub (
+ index,
+ &state->tokens[index].issue_pub),
+ TALER_TESTING_make_trait_token_issue_sig (
+ index,
+ &state->tokens[index].issue_sig),
+ TALER_TESTING_trait_end ()
+ };
+
+ return TALER_TESTING_get_trait (traits,
+ ret,
+ trait,
+ index);
+ }
+}
+
+
+/**
+ * Free the state of a fountain withdraw CMD, and possibly cancel
+ * pending operations thereof.
+ *
+ * @param cls closure.
+ * @param cmd command being run.
+ */
+static void
+fountain_withdraw_cleanup (void *cls,
+ const struct TALER_TESTING_Command *cmd)
+{
+ struct FountainWithdrawState *state = cls;
+
+ if (NULL != state->info_handle)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "GET /fountain/info operation did not complete\n");
+ TALER_MERCHANT_get_fountain_info_cancel (state->info_handle);
+ }
+ if (NULL != state->withdraw_handle)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "POST /fountain/withdraw operation did not complete\n");
+ TALER_MERCHANT_post_fountain_withdraw_cancel (state->withdraw_handle);
+ }
+ for (unsigned int i = 0; i < state->num_tokens; i++)
+ {
+ struct FountainToken *token = &state->tokens[i];
+
+ if (NULL != token->issue_pub.public_key)
+ GNUNET_CRYPTO_blind_sign_pub_decref (token->issue_pub.public_key);
+ if (NULL != token->envelope.blinded_pub)
+ GNUNET_CRYPTO_blinded_message_decref (token->envelope.blinded_pub);
+ if (NULL != token->issue_sig.signature)
+ GNUNET_CRYPTO_unblinded_sig_decref (token->issue_sig.signature);
+ /* Note: blinding_inputs is a plain copy of the RSA singleton
+ (a static without refcount), so it must not be decref'ed. */
+ }
+ GNUNET_free (state->tokens);
+ json_decref (state->first_reply);
+ GNUNET_free (state);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_fountain_withdraw (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *fountain_reference,
+ const char *token_family_slug,
+ struct GNUNET_TIME_Timestamp valid_at,
+ unsigned int num_tokens)
+{
+ struct FountainWithdrawState *state;
+
+ GNUNET_assert (num_tokens > 0);
+ state = GNUNET_new (struct FountainWithdrawState);
+ state->merchant_url = merchant_url;
+ state->http_status = http_status;
+ state->fountain_reference = fountain_reference;
+ state->token_family_slug = token_family_slug;
+ state->valid_at = valid_at;
+ state->num_tokens = num_tokens;
+ state->tokens = GNUNET_new_array (num_tokens,
+ struct FountainToken);
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = state,
+ .label = label,
+ .run = &fountain_withdraw_run,
+ .cleanup = &fountain_withdraw_cleanup,
+ .traits = &fountain_withdraw_traits
+ };
+
+ return cmd;
+ }
+}
+
+
+/* end of testing_api_cmd_fountain_withdraw.c */
diff --git a/src/testing/testing_api_cmd_post_fountains.c b/src/testing/testing_api_cmd_post_fountains.c
@@ -0,0 +1,261 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as
+ published by the Free Software Foundation; either version 3, or
+ (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public
+ License along with TALER; see the file COPYING. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+
+/**
+ * @file src/testing/testing_api_cmd_post_fountains.c
+ * @brief command to run POST /private/fountains (DD 98)
+ * @author Bohdan Potuzhnyi
+ */
+#include "platform.h"
+struct PostFountainsState;
+#define TALER_MERCHANT_POST_PRIVATE_FOUNTAINS_RESULT_CLOSURE \
+ struct PostFountainsState
+#include <gnunet/gnunet_time_lib.h>
+#include <taler/taler_exchange_service.h>
+#include <taler/taler_testing_lib.h>
+#include "taler/taler_merchant_service.h"
+#include "taler/taler_merchant_testing_lib.h"
+#include <taler/merchant/post-private-fountains.h>
+
+
+/**
+ * State of a "POST /private/fountains" CMD.
+ */
+struct PostFountainsState
+{
+
+ /**
+ * Expected status code.
+ */
+ unsigned int http_status;
+
+ /**
+ * Handle for a "POST /private/fountains" request.
+ */
+ struct TALER_MERCHANT_PostPrivateFountainsHandle *handle;
+
+ /**
+ * The interpreter state.
+ */
+ struct TALER_TESTING_Interpreter *is;
+
+ /**
+ * Base URL of the merchant serving the request.
+ */
+ const char *merchant_url;
+
+ /**
+ * Description of the fountain.
+ */
+ const char *description;
+
+ /**
+ * Poll frequency of the fountain.
+ */
+ struct GNUNET_TIME_Relative poll_freq;
+
+ /**
+ * Grants of the fountain.
+ */
+ const struct TALER_MERCHANT_FountainGrant *grants;
+
+ /**
+ * Length of the @e grants array.
+ */
+ unsigned int num_grants;
+
+ /**
+ * Identifier of the created fountain.
+ */
+ char *fountain_id;
+
+ /**
+ * Bearer credential of the created fountain.
+ */
+ char *fountain_secret;
+};
+
+
+/**
+ * Callback for a POST /private/fountains operation.
+ *
+ * @param cls closure for this function
+ * @param pfr response being processed
+ */
+static void
+post_fountains_cb (struct PostFountainsState *state,
+ const struct
+ TALER_MERCHANT_PostPrivateFountainsResponse *pfr)
+{
+
+ state->handle = NULL;
+ if (state->http_status != pfr->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u (%d) to command %s\n",
+ pfr->hr.http_status,
+ (int) pfr->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (state->is));
+ TALER_TESTING_interpreter_fail (state->is);
+ return;
+ }
+ switch (pfr->hr.http_status)
+ {
+ case MHD_HTTP_OK:
+ state->fountain_id = GNUNET_strdup (pfr->details.ok.fountain_id);
+ state->fountain_secret = GNUNET_strdup (pfr->details.ok.fountain_secret);
+ break;
+ case MHD_HTTP_BAD_REQUEST:
+ case MHD_HTTP_UNAUTHORIZED:
+ case MHD_HTTP_FORBIDDEN:
+ case MHD_HTTP_NOT_FOUND:
+ break;
+ default:
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Unhandled HTTP status %u for POST /private/fountains.\n",
+ pfr->hr.http_status);
+ }
+ TALER_TESTING_interpreter_next (state->is);
+}
+
+
+/**
+ * Run the "POST /private/fountains" CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run now.
+ * @param is interpreter state.
+ */
+static void
+post_fountains_run (void *cls,
+ const struct TALER_TESTING_Command *cmd,
+ struct TALER_TESTING_Interpreter *is)
+{
+ struct PostFountainsState *state = cls;
+
+ state->is = is;
+ state->handle = TALER_MERCHANT_post_private_fountains_create (
+ TALER_TESTING_interpreter_get_context (is),
+ state->merchant_url,
+ state->description,
+ state->poll_freq,
+ state->num_grants,
+ state->grants);
+ {
+ enum TALER_ErrorCode ec;
+
+ ec = TALER_MERCHANT_post_private_fountains_start (
+ state->handle,
+ &post_fountains_cb,
+ state);
+ GNUNET_assert (TALER_EC_NONE == ec);
+ }
+}
+
+
+/**
+ * Offers information from the "POST /private/fountains" CMD state to
+ * other commands.
+ *
+ * @param cls closure
+ * @param[out] ret result (could be anything)
+ * @param trait name of the trait
+ * @param index index number of the object to extract.
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+post_fountains_traits (void *cls,
+ const void **ret,
+ const char *trait,
+ unsigned int index)
+{
+ struct PostFountainsState *state = cls;
+ struct TALER_TESTING_Trait traits[] = {
+ TALER_TESTING_make_trait_fountain_id (state->fountain_id),
+ TALER_TESTING_make_trait_fountain_secret (state->fountain_secret),
+ TALER_TESTING_trait_end ()
+ };
+
+ return TALER_TESTING_get_trait (traits,
+ ret,
+ trait,
+ index);
+}
+
+
+/**
+ * Free the state of a "POST /private/fountains" CMD, and possibly
+ * cancel a pending operation thereof.
+ *
+ * @param cls closure.
+ * @param cmd command being run.
+ */
+static void
+post_fountains_cleanup (void *cls,
+ const struct TALER_TESTING_Command *cmd)
+{
+ struct PostFountainsState *state = cls;
+
+ if (NULL != state->handle)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "POST /private/fountains operation did not complete\n");
+ TALER_MERCHANT_post_private_fountains_cancel (state->handle);
+ }
+ GNUNET_free (state->fountain_id);
+ GNUNET_free (state->fountain_secret);
+ GNUNET_free (state);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_fountains (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *description,
+ struct GNUNET_TIME_Relative poll_freq,
+ unsigned int num_grants,
+ const struct TALER_MERCHANT_FountainGrant *grants)
+{
+ struct PostFountainsState *state;
+
+ state = GNUNET_new (struct PostFountainsState);
+ state->merchant_url = merchant_url;
+ state->http_status = http_status;
+ state->description = description;
+ state->poll_freq = poll_freq;
+ state->num_grants = num_grants;
+ state->grants = grants;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = state,
+ .label = label,
+ .run = &post_fountains_run,
+ .cleanup = &post_fountains_cleanup,
+ .traits = &post_fountains_traits
+ };
+
+ return cmd;
+ }
+}
+
+
+/* end of testing_api_cmd_post_fountains.c */
diff --git a/src/util/token_family_parse.c b/src/util/token_family_parse.c
@@ -177,6 +177,15 @@ parse_token_details (void *cls,
return GNUNET_SYSERR;
}
+ /* Check the size_t count before allocation or conversion to the
+ unsigned array length. A remote response must not reach the
+ allocator's fatal size limit. */
+ if (json_array_size (trusted_domains) >
+ TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS)
+ {
+ GNUNET_break_op (0);
+ return GNUNET_SYSERR;
+ }
GNUNET_array_grow (family->details.subscription.trusted_domains,
family->details.subscription.trusted_domains_len,
json_array_size (trusted_domains));
@@ -224,6 +233,12 @@ parse_token_details (void *cls,
return GNUNET_SYSERR;
}
+ if (json_array_size (expected_domains) >
+ TALER_MERCHANT_MAX_TOKEN_FAMILY_DOMAINS)
+ {
+ GNUNET_break_op (0);
+ return GNUNET_SYSERR;
+ }
GNUNET_array_grow (family->details.discount.expected_domains,
family->details.discount.expected_domains_len,
json_array_size (expected_domains));
@@ -345,6 +360,12 @@ parse_token_families (void *cls,
return GNUNET_SYSERR;
}
+ if (json_array_size (keys) > TALER_MERCHANT_MAX_TOKEN_FAMILY_KEYS)
+ {
+ GNUNET_break_op (0);
+ TALER_MERCHANT_contract_token_family_free (&family);
+ return GNUNET_SYSERR;
+ }
GNUNET_array_grow (family.keys,
family.keys_len,
json_array_size (keys));