exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

commit 2f28326170fe9fef8cadafe07c3f975b901dd573
parent 0acdec7b3de4e2019660dbbfa25a19f3c90a2b38
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 17 Oct 2026 15:57:57 +0200

add max_p2p_expiration option

Diffstat:
Msrc/auditor/generate-auditor-basedb.conf | 6+++---
Msrc/exchange/exchange.conf | 8++++++++
Msrc/exchange/taler-exchange-httpd.c | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchange/taler-exchange-httpd.h | 8++++++++
Msrc/exchange/taler-exchange-httpd_common_deposit.c | 22+++++++++++++++++++++-
Msrc/exchange/taler-exchange-httpd_common_deposit.h | 7++++++-
Msrc/exchange/taler-exchange-httpd_get-keys.c | 16++++++++++------
Msrc/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-create.c | 22+++++++++++++++++++++-
Msrc/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c | 3++-
Msrc/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-open.c | 3++-
Msrc/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-purse.c | 19+++++++++++++++++++
Msrc/testing/test_exchange_p2p.c | 30++++++++++++++++++++++++++++++
12 files changed, 367 insertions(+), 14 deletions(-)

diff --git a/src/auditor/generate-auditor-basedb.conf b/src/auditor/generate-auditor-basedb.conf @@ -14,11 +14,11 @@ SIGNKEY_LEGAL_DURATION = 4 weeks db = postgres BASE_URL = http://localhost:8081/ IDLE_RESERVE_EXPIRATION_TIME = 4 weeks -LEGAL_RESERVE_EXPIRATION_TIME = 4 weeks +LEGAL_RESERVE_EXPIRATION_TIME = 7 years [exchangedb] IDLE_RESERVE_EXPIRATION_TIME = 4 weeks -LEGAL_RESERVE_EXPIRATION_TIME = 4 weeks +LEGAL_RESERVE_EXPIRATION_TIME = 7 years AGGREGATOR_SHIFT = 1 s DEFAULT_PURSE_LIMIT = 1 @@ -45,7 +45,7 @@ CONFIG = postgresql:///auditor-basedb CONFIG = postgres:///auditor-basedb SQL_DIR = ${DATADIR}sql/ IDLE_RESERVE_EXPIRATION_TIME = 4 weeks -LEGAL_RESERVE_EXPIRATION_TIME = 4 weeks +LEGAL_RESERVE_EXPIRATION_TIME = 7 years [exchange-account-1] PAYTO_URI = payto://iban/DE89370400440532013000?receiver-name=Exchange+Company diff --git a/src/exchange/exchange.conf b/src/exchange/exchange.conf @@ -82,6 +82,14 @@ ASSET_TYPE = "fiat" # FIXME: document! ROUTER_IDLE_SLEEP_INTERVAL = 60 s +# How far in the future may a purse expire? An expiring purse refunds the +# coins deposited into it, and the auditor can only account for such a +# refund while the denomination of the coin is still legally valid, so this +# must not exceed the shortest DURATION_LEGAL of any denomination. The +# exchange refuses to start if it does, and defaults to that shortest +# DURATION_LEGAL (with a warning) if this option is not given. +# PURSE_EXPIRATION_LIMIT = 3 years + # How big is an individual shard to be processed # by taler-exchange-expire (in time). It may take # this much time for an expired purse to be really diff --git a/src/exchange/taler-exchange-httpd.c b/src/exchange/taler-exchange-httpd.c @@ -142,6 +142,8 @@ struct GNUNET_TIME_Relative TEH_reserve_closing_delay; struct GNUNET_TIME_Relative TEH_default_p2p_expiration; +struct GNUNET_TIME_Relative TEH_purse_expiration_limit; + struct GNUNET_TIME_Relative TEH_aml_program_timeout; struct TALER_MasterPublicKeyP TEH_master_public_key; @@ -2190,6 +2192,238 @@ handle_mhd_request (void *cls, /** + * Closure for #denomination_duration_cb(). + */ +struct LegalDurationContext +{ + /** + * Shortest DURATION_LEGAL of any denomination seen so far, + * #GNUNET_TIME_UNIT_FOREVER_REL if no denomination was seen. + */ + struct GNUNET_TIME_Relative min; + + /** + * Name of the section @e min was read from, NULL if none. + */ + char *section; + + /** + * Longest lifetime (DURATION_WITHDRAW plus DURATION_SPEND plus + * DURATION_LEGAL) of any denomination seen so far. + */ + struct GNUNET_TIME_Relative max_lifetime; + + /** + * Name of the section @e max_lifetime was computed from, NULL if none. + */ + char *lifetime_section; + + /** + * Set to true if a denomination section lacks one of the durations. + */ + bool invalid; +}; + + +/** + * Function called on each configuration section to find the shortest + * DURATION_LEGAL and the longest total lifetime configured for any + * denomination. + * + * @param cls a `struct LegalDurationContext *` + * @param section name of the section + */ +static void +denomination_duration_cb (void *cls, + const char *section) +{ + struct LegalDurationContext *ldc = cls; + struct GNUNET_TIME_Relative withdraw_duration; + struct GNUNET_TIME_Relative deposit_duration; + struct GNUNET_TIME_Relative legal_duration; + struct GNUNET_TIME_Relative lifetime; + + if (0 != strncasecmp (section, + "coin_", + strlen ("coin_"))) + return; /* not a denomination type definition */ + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_time (TEH_cfg, + section, + "DURATION_LEGAL", + &legal_duration)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + section, + "DURATION_LEGAL"); + ldc->invalid = true; + return; + } + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_time (TEH_cfg, + section, + "DURATION_WITHDRAW", + &withdraw_duration)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + section, + "DURATION_WITHDRAW"); + ldc->invalid = true; + return; + } + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_time (TEH_cfg, + section, + "DURATION_SPEND", + &deposit_duration)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + section, + "DURATION_SPEND"); + ldc->invalid = true; + return; + } + lifetime = GNUNET_TIME_relative_add ( + GNUNET_TIME_relative_add (withdraw_duration, + deposit_duration), + legal_duration); + if (GNUNET_TIME_relative_cmp (lifetime, + >, + ldc->max_lifetime)) + { + ldc->max_lifetime = lifetime; + GNUNET_free (ldc->lifetime_section); + ldc->lifetime_section = GNUNET_strdup (section); + } + if (GNUNET_TIME_relative_cmp (legal_duration, + >=, + ldc->min)) + return; + ldc->min = legal_duration; + GNUNET_free (ldc->section); + ldc->section = GNUNET_strdup (section); +} + + +/** + * Determine #TEH_purse_expiration_limit from the configuration. A purse + * that is not decided refunds its coins when it expires, and the auditor + * can only account for such a refund while the denomination of the coin is + * still legally valid. The limit must therefore not exceed the shortest + * DURATION_LEGAL of any denomination: as a deposit into a purse requires + * the denomination to be valid for deposits, its legal expiration is then + * at least DURATION_LEGAL away, and a purse created now expires at the + * latest after #TEH_purse_expiration_limit. + * + * @return #GNUNET_OK on success, + * #GNUNET_SYSERR if the configuration is invalid + */ +static enum GNUNET_GenericReturnValue +load_denomination_duration_limits (void) +{ + struct LegalDurationContext ldc = { + .min = GNUNET_TIME_UNIT_FOREVER_REL + }; + struct GNUNET_TIME_Relative lret; + enum GNUNET_GenericReturnValue ret = GNUNET_SYSERR; + + GNUNET_CONFIGURATION_iterate_sections (TEH_cfg, + &denomination_duration_cb, + &ldc); + if (ldc.invalid) + goto cleanup; + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_time (TEH_cfg, + "exchange", + "PURSE_EXPIRATION_LIMIT", + &TEH_purse_expiration_limit)) + { + TEH_purse_expiration_limit = ldc.min; + GNUNET_log ( + GNUNET_ERROR_TYPE_WARNING, + "PURSE_EXPIRATION_LIMIT not configured in section `exchange', using the shortest DURATION_LEGAL of any denomination (%s)\n", + GNUNET_TIME_relative2s (TEH_purse_expiration_limit, + true)); + } + else if (GNUNET_TIME_relative_cmp (TEH_purse_expiration_limit, + >, + ldc.min)) + { + GNUNET_log ( + GNUNET_ERROR_TYPE_ERROR, + "PURSE_EXPIRATION_LIMIT (%s) in section `exchange' exceeds DURATION_LEGAL of denomination `%s'; a purse could then expire after the denomination of a coin in it stopped being legally valid\n", + GNUNET_TIME_relative2s (TEH_purse_expiration_limit, + true), + ldc.section); + goto cleanup; + } + if (GNUNET_TIME_relative_cmp (TEH_default_p2p_expiration, + >, + TEH_purse_expiration_limit)) + { + /* #GNUNET_TIME_relative2s() returns a shared buffer, so the two + durations cannot be rendered in one statement. */ + char *dp2p = GNUNET_strdup ( + GNUNET_TIME_relative2s (TEH_default_p2p_expiration, + true)); + + GNUNET_log ( + GNUNET_ERROR_TYPE_ERROR, + "DEFAULT_P2P_EXPIRATION (%s) in section `exchange' exceeds PURSE_EXPIRATION_LIMIT (%s), so the default expiration would be refused\n", + dp2p, + GNUNET_TIME_relative2s (TEH_purse_expiration_limit, + true)); + GNUNET_free (dp2p); + goto cleanup; + } + /* The auditor books a denomination as finished once its legal period is + over, which requires the denomination to still be in the exchange's + database at that point. exchange_do_main_gc() keeps a denomination as + long as a withdraw row refers to it, and a withdraw row lives until the + reserve's gc_date (the time of the withdrawal plus + LEGAL_RESERVE_EXPIRATION_TIME) has passed. In the worst case a coin is + withdrawn at the very start of the denomination, so the whole lifetime + of the denomination must fit into LEGAL_RESERVE_EXPIRATION_TIME. */ + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_time (TEH_cfg, + "exchangedb", + "LEGAL_RESERVE_EXPIRATION_TIME", + &lret)) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + "exchangedb", + "LEGAL_RESERVE_EXPIRATION_TIME"); + goto cleanup; + } + if (GNUNET_TIME_relative_cmp (lret, + <, + ldc.max_lifetime)) + { + /* #GNUNET_TIME_relative2s() returns a shared buffer, so the two + durations cannot be rendered in one statement. */ + char *lifetime = GNUNET_strdup ( + GNUNET_TIME_relative2s (ldc.max_lifetime, + true)); + + GNUNET_log ( + GNUNET_ERROR_TYPE_ERROR, + "DURATION_WITHDRAW plus DURATION_SPEND plus DURATION_LEGAL of denomination `%s' (%s) exceeds LEGAL_RESERVE_EXPIRATION_TIME (%s) of section `exchangedb'; the exchange could then garbage-collect the denomination before the auditor is allowed to finish accounting for it\n", + ldc.lifetime_section, + lifetime, + GNUNET_TIME_relative2s (lret, + true)); + GNUNET_free (lifetime); + goto cleanup; + } + ret = GNUNET_OK; +cleanup: + GNUNET_free (ldc.section); + GNUNET_free (ldc.lifetime_section); + return ret; +} + + +/** * Load configuration parameters for the exchange * server into the corresponding global variables. * @@ -2283,6 +2517,9 @@ exchange_serve_process_config (const char *cfg_fn) 4); } if (GNUNET_OK != + load_denomination_duration_limits ()) + return EXIT_NOTCONFIGURED; + if (GNUNET_OK != GNUNET_CONFIGURATION_get_value_time (TEH_cfg, "exchangedb", "MAX_AML_PROGRAM_RUNTIME", diff --git a/src/exchange/taler-exchange-httpd.h b/src/exchange/taler-exchange-httpd.h @@ -47,6 +47,14 @@ extern struct GNUNET_TIME_Relative TEH_reserve_closing_delay; extern struct GNUNET_TIME_Relative TEH_default_p2p_expiration; /** + * How far in the future may a purse expire? A purse that expires refunds + * the coins deposited into it, and that refund must still be auditable, so + * the expiration is bounded by the legal expiration of the denominations + * of the coins that can be deposited into the purse. + */ +extern struct GNUNET_TIME_Relative TEH_purse_expiration_limit; + +/** * How long do we allow AML programs to run? */ extern struct GNUNET_TIME_Relative TEH_aml_program_timeout; diff --git a/src/exchange/taler-exchange-httpd_common_deposit.c b/src/exchange/taler-exchange-httpd_common_deposit.c @@ -27,7 +27,8 @@ enum GNUNET_GenericReturnValue TEH_common_purse_deposit_parse_coin ( struct MHD_Connection *connection, struct TEH_PurseDepositedCoin *coin, - const json_t *jcoin) + const json_t *jcoin, + struct GNUNET_TIME_Timestamp purse_expiration) { struct GNUNET_JSON_Specification spec[] = { TALER_JSON_spec_amount ("amount", @@ -124,6 +125,25 @@ TEH_common_purse_deposit_parse_coin ( "PURSE CREATE")) ? GNUNET_NO : GNUNET_SYSERR; } + if ( (! GNUNET_TIME_absolute_is_zero (purse_expiration.abs_time)) && + (GNUNET_TIME_timestamp_cmp (purse_expiration, + >, + dk->meta.expire_legal)) ) + { + /* The purse can expire with the coin still in it and refund it then. + After expire_legal the exchange may forget the denomination, so + such a refund could no longer be accounted for. TEH_cfg bounds + the purse expiration for new purses, but a denomination issued + under a shorter DURATION_LEGAL may still be in the key state. */ + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return (MHD_YES == + TALER_MHD_reply_with_ec ( + connection, + TALER_EC_EXCHANGE_PURSE_DENOMINATION_LEGAL_EXPIRES_TOO_SOON, + GNUNET_TIME_timestamp2s (dk->meta.expire_legal))) + ? GNUNET_NO : GNUNET_SYSERR; + } if (GNUNET_TIME_absolute_is_future (dk->meta.start.abs_time)) { /* This denomination is not yet valid */ diff --git a/src/exchange/taler-exchange-httpd_common_deposit.h b/src/exchange/taler-exchange-httpd_common_deposit.h @@ -89,6 +89,10 @@ struct TEH_PurseDepositedCoin * @param[in,out] connection our HTTP connection * @param[out] coin coin to initialize * @param jcoin coin to parse + * @param purse_expiration when the purse the coin is deposited into expires, + * #GNUNET_TIME_UNIT_ZERO_TS if the coin is not deposited into a purse; + * a purse must not outlive the legal expiration of the denomination, + * as it refunds the coin when it expires * @return #GNUNET_OK on success, #GNUNET_NO if an error was returned, * #GNUNET_SYSERR on failure and no error could be returned */ @@ -96,7 +100,8 @@ enum GNUNET_GenericReturnValue TEH_common_purse_deposit_parse_coin ( struct MHD_Connection *connection, struct TEH_PurseDepositedCoin *coin, - const json_t *jcoin); + const json_t *jcoin, + struct GNUNET_TIME_Timestamp purse_expiration); /** diff --git a/src/exchange/taler-exchange-httpd_get-keys.c b/src/exchange/taler-exchange-httpd_get-keys.c @@ -1831,7 +1831,7 @@ find_withdraw_family_cb (void *cls, return GNUNET_OK; } family->next_start = GNUNET_TIME_timestamp_min (family->next_start, - dk->meta.start); + dk->meta.start); return GNUNET_OK; } @@ -1846,8 +1846,8 @@ find_withdraw_family_cb (void *cls, */ static enum GNUNET_GenericReturnValue select_withdraw_denomination_cb (void *cls, - const struct GNUNET_HashCode *h_denom_pub, - void *value) + const struct GNUNET_HashCode *h_denom_pub, + void *value) { struct SelectWithdrawDenominationsContext *ctx = cls; struct TEH_DenominationKey *dk = value; @@ -2013,7 +2013,7 @@ build_withdraw_auditors (const struct TEH_KeyStateHandle *ksh) dk = GNUNET_CONTAINER_multihashmap_get (ksh->denomkey_map, &h_denom_pub.hash); if ( (NULL != dk) && - dk->withdraw_relevant ) + dk->withdraw_relevant) GNUNET_assert (0 == json_array_append (filtered_keys, denom_sig)); @@ -2059,7 +2059,8 @@ setup_response_headers (struct TEH_KeyStateHandle *ksh, MHD_HTTP_HEADER_CACHE_CONTROL, withdraw_filtered ? "public,must-revalidate" - : "public,must-revalidate,max-age=86400") + : + "public,must-revalidate,max-age=86400") ); if (! GNUNET_TIME_relative_is_zero (ksh->rekey_frequency)) { @@ -2272,6 +2273,8 @@ create_krd (struct TEH_KeyStateHandle *ksh, TEH_reserve_closing_delay), GNUNET_JSON_pack_time_rel ("default_p2p_push_expiration", TEH_default_p2p_expiration), + GNUNET_JSON_pack_time_rel ("max_p2p_expiration", + TEH_purse_expiration_limit), GNUNET_JSON_pack_array_incref ("signkeys", signkeys), GNUNET_JSON_pack_array_incref ("recoup", @@ -2933,7 +2936,8 @@ finish_keys_response (struct TEH_KeyStateHandle *ksh) true)) { GNUNET_log (GNUNET_ERROR_TYPE_WARNING, - "Failed to generate withdrawal-focused key response data\n"); + "Failed to generate withdrawal-focused key response data\n") + ; goto CLEANUP; } GNUNET_assert (1 == ksh->withdraw_krd_array_length); diff --git a/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-create.c b/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-create.c @@ -28,6 +28,7 @@ #include "taler/taler_mhd_lib.h" #include "taler-exchange-httpd_common_deposit.h" #include "taler-exchange-httpd_post-purses-PURSE_PUB-create.h" +#include "taler-exchange-httpd.h" #include "taler-exchange-httpd_responses.h" #include "exchangedb_lib.h" #include "taler-exchange-httpd_get-keys.h" @@ -417,7 +418,8 @@ parse_coin (struct MHD_Connection *connection, if (GNUNET_OK != (iret = TEH_common_purse_deposit_parse_coin (connection, coin, - jcoin))) + jcoin, + pcc->pd.purse_expiration))) return iret; if (GNUNET_OK != (iret = TEH_common_deposit_check_purse_deposit ( @@ -522,6 +524,24 @@ TEH_handler_purses_create ( TALER_EC_EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER, NULL); } + { + struct GNUNET_TIME_Timestamp limit + = GNUNET_TIME_relative_to_timestamp (TEH_purse_expiration_limit); + + if (GNUNET_TIME_timestamp_cmp (pcc.pd.purse_expiration, + >, + limit)) + { + /* An expiring purse refunds its coins, and that refund must remain + auditable; see TEH_purse_expiration_limit. */ + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return TALER_MHD_reply_with_ec ( + connection, + TALER_EC_EXCHANGE_PURSE_CREATE_EXPIRATION_TOO_LATE, + GNUNET_TIME_timestamp2s (limit)); + } + } pcc.num_coins = json_array_size (deposits); if ( (0 == pcc.num_coins) || (pcc.num_coins > TALER_MAX_COINS) ) diff --git a/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c b/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c @@ -320,7 +320,8 @@ parse_coin (struct MHD_Connection *connection, if (GNUNET_OK != (iret = TEH_common_purse_deposit_parse_coin (connection, coin, - jcoin))) + jcoin, + pcc->purse_expiration))) return iret; if (GNUNET_OK != (iret = TEH_common_deposit_check_purse_deposit ( diff --git a/src/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-open.c b/src/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-open.c @@ -393,7 +393,8 @@ TEH_handler_reserves_open (struct TEH_RequestContext *rc, rc->connection, coin, json_array_get (payments, - i)); + i), + GNUNET_TIME_UNIT_ZERO_TS); if (GNUNET_SYSERR == res) { GNUNET_break (0); diff --git a/src/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-purse.c b/src/exchange/taler-exchange-httpd_post-reserves-RESERVE_PUB-purse.c @@ -30,6 +30,7 @@ #include "taler/taler_mhd_lib.h" #include "taler-exchange-httpd_common_kyc.h" #include "taler-exchange-httpd_post-reserves-RESERVE_PUB-purse.h" +#include "taler-exchange-httpd.h" #include "taler-exchange-httpd_responses.h" #include "exchangedb_lib.h" #include "taler-exchange-httpd_get-keys.h" @@ -720,6 +721,24 @@ TEH_handler_reserves_purse ( TALER_EC_EXCHANGE_RESERVES_PURSE_EXPIRATION_IS_NEVER, NULL); } + { + struct GNUNET_TIME_Timestamp limit + = GNUNET_TIME_relative_to_timestamp (TEH_purse_expiration_limit); + + if (GNUNET_TIME_timestamp_cmp (rpc->pd.purse_expiration, + >, + limit)) + { + /* A purse that is not paid in full refunds the coins that were + deposited into it when it expires, and that refund must remain + auditable; see TEH_purse_expiration_limit. */ + GNUNET_break_op (0); + return TALER_MHD_reply_with_ec ( + connection, + TALER_EC_EXCHANGE_RESERVES_PURSE_EXPIRATION_TOO_LATE, + GNUNET_TIME_timestamp2s (limit)); + } + } { struct TEH_KeyStateHandle *keys; diff --git a/src/testing/test_exchange_p2p.c b/src/testing/test_exchange_p2p.c @@ -217,9 +217,39 @@ run (void *cls, "push-get-contract", "create-reserve-2"), + /** + * A purse expiring after PURSE_EXPIRATION_LIMIT is refused: it would + * refund its coins at a point where the auditor can no longer account + * for the refund. The limit defaults to the shortest DURATION_LEGAL + * of any denomination, which is 3 years in the test configuration. + */ + TALER_TESTING_cmd_purse_create_with_deposit ( + "purse-with-deposit-expiring-after-limit", + MHD_HTTP_BAD_REQUEST, + "{\"amount\":\"EUR:1\",\"summary\":\"ice cream\"}", + true, /* upload contract */ + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_YEARS, + 4), /* expiration */ + "withdraw-coin-1", + "EUR:1.01", + NULL), TALER_TESTING_cmd_end () }; struct TALER_TESTING_Command pull[] = { + /** + * The same limit applies to a purse created together with its merge: + * such a purse also refunds the coins deposited into it if it is not + * paid in full by the time it expires. + */ + TALER_TESTING_cmd_purse_create_with_reserve ( + "purse-create-with-reserve-expiring-after-limit", + MHD_HTTP_BAD_REQUEST, + "{\"amount\":\"EUR:1\",\"summary\":\"ice cream\"}", + true /* upload contract */, + true /* pay purse fee */, + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_YEARS, + 4), /* expiration */ + "create-reserve-1"), TALER_TESTING_cmd_purse_create_with_reserve ( "purse-create-with-reserve", MHD_HTTP_OK,