commit 40c7fd37b6081a418d97632b7d23fe429b3500c4
parent a02f30c618222c5c1ecaab34e7a2b03de280ab18
Author: Iván Ávalos <avalos@disroot.org>
Date: Wed, 5 Mar 2025 18:50:08 +0100
WIP: tokens implementation
Diffstat:
17 files changed, 1995 insertions(+), 237 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts
@@ -0,0 +1,239 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU 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.
+
+ GNU 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
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ AbsoluteTime,
+ Duration,
+ Order,
+ OrderInputType,
+ OrderOutputType,
+ PreparePayResultType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolTimestamp,
+ TokenFamilyKind,
+} from "@gnu-taler/taler-util";
+
+export async function runWalletTokensTest(t: GlobalTestState) {
+ let { bankClient, exchange, merchant, walletClient } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantApi = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ // withdraw some test money
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ // setup token family
+ succeedOrThrow(
+ await merchantApi.createTokenFamily(undefined, {
+ kind: TokenFamilyKind.Discount,
+ slug: "test_discount",
+ name: "Test discount",
+ description: "This is a test discount",
+ valid_after: TalerProtocolTimestamp.now(),
+ valid_before: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({years: 1}),
+ ),
+ ),
+ duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({days: 90}),
+ ),
+ validity_granularity: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({days: 1}),
+ ),
+ }),
+ );
+
+ // setup order with token output
+ let orderResp = succeedOrThrow(
+ await merchantApi.createOrder(undefined, {
+ order: {
+ version: 1,
+ summary: "Test order",
+ timestamp: TalerProtocolTimestamp.now(),
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({days: 1}),
+ ),
+ ),
+ choices: [
+ {
+ amount: "TESTKUDOS:1",
+ inputs: [],
+ outputs: [{
+ type: OrderOutputType.Token,
+ token_family_slug: "test_discount",
+ }],
+ },
+ ],
+ },
+ }),
+ );
+
+ let orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderResp.order_id),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ let talerPayUri = orderStatus.taler_pay_uri;
+ let orderId = orderResp.order_id;
+
+ let preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.ChoiceSelection,
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: 0,
+ });
+
+ orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderId),
+ );
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ // setup order with token input
+ const order: Order = {
+ version: 1,
+ summary: "Test order",
+ timestamp: TalerProtocolTimestamp.now(),
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({days: 1}),
+ ),
+ ),
+ choices: [
+ {
+ amount: "TESTKUDOS:2",
+ inputs: [],
+ outputs: [],
+ },
+ {
+ amount: "TESTKUDOS:1",
+ inputs: [{
+ type: OrderInputType.Token,
+ token_family_slug: "test_discount",
+ }],
+ },
+ ],
+ };
+
+ orderResp = succeedOrThrow(
+ await merchantApi.createOrder(undefined, { order }),
+ );
+
+ orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderResp.order_id),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ talerPayUri = orderStatus.taler_pay_uri;
+ orderId = orderResp.order_id;
+
+ preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ }
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.ChoiceSelection,
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: 1,
+ });
+
+ orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderId),
+ );
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ // setup another order with token input
+ orderResp = succeedOrThrow(
+ await merchantApi.createOrder(undefined, { order }),
+ );
+
+ orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderResp.order_id),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ talerPayUri = orderStatus.taler_pay_uri;
+ orderId = orderResp.order_id;
+
+ preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.ChoiceSelection,
+ );
+
+ // should fail because we have no tokens left
+ t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: 1,
+ })
+ });
+
+ orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(undefined, orderId),
+ );
+
+ t.assertTrue(orderStatus.order_status === "claimed");
+}
+
+runWalletTokensTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -160,6 +160,7 @@ import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
import { runWithdrawalPrepareTest } from "./test-withdrawal-prepare.js";
import { runKycMerchantDepositRewriteTest } from "./test-kyc-merchant-deposit-rewrite.js";
import { runKycMerchantDepositTest } from "./test-kyc-merchant-deposit.js";
+import { runWalletTokensTest } from "./test-wallet-tokens.js";
/**
* Test runner.
@@ -254,6 +255,7 @@ const allTests: TestMainFunction[] = [
runWalletDevExperimentsTest,
runWalletBalanceZeroTest,
runWalletInsufficientBalanceTest,
+ runWalletTokensTest,
runWalletWirefeesTest,
runDenomLostTest,
runWalletDenomExpireTest,
@@ -640,6 +642,7 @@ export function reportAndQuit(
console.log("test suite was interrupted");
}
console.log(`See ${resultsFile} for details`);
+ console.log(`Logs can be found in ${testRootDir}`);
console.log(`Skipped: ${numSkip}/${numTotal}`);
console.log(`Failed: ${numFail}/${numTotal}`);
console.log(`Passed: ${numPass}/${numTotal}`);
diff --git a/packages/taler-util/src/contract-terms.ts b/packages/taler-util/src/contract-terms.ts
@@ -124,6 +124,9 @@ export namespace ContractTermsUtil {
* to forgettable fields and other restrictions for forgettable JSON.
*/
export function validateForgettable(anyJson: any): boolean {
+ if (anyJson === undefined) {
+ return true;
+ }
if (typeof anyJson === "string") {
return true;
}
diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts
@@ -37,6 +37,11 @@ import {
DenomKeyType,
DenominationPubKey,
} from "./types-taler-exchange.js";
+import {
+ TokenEnvelope,
+ TokenIssuePublicKey,
+} from "./types-taler-merchant.js";
+import { PayWalletData } from "./types-taler-wallet.js";
const isEddsaPubP: unique symbol = Symbol("isEddsaPubP");
type FlavorEddsaPubP = {
@@ -917,6 +922,64 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array {
}
}
+/**
+ * Hash a token issue public key.
+ */
+export function hashTokenIssuePub(pub: TokenIssuePublicKey): Uint8Array {
+ if (pub.cipher === DenomKeyType.Rsa) {
+ const dec = decodeCrock(pub.rsa_pub);
+ return hash(dec);
+ } else if (pub.cipher === DenomKeyType.ClauseSchnorr) {
+ const dec = decodeCrock(pub.cs_pub);
+ return hash(dec);
+ } else {
+ throw Error(
+ `unsupported cipher (${
+ (pub as TokenIssuePublicKey).cipher
+ }), unable to hash`,
+ );
+ }
+}
+
+/**
+ * Hash a token envelope.
+ */
+export function hashTokenEv(
+ tokenEv: TokenEnvelope,
+ tokenIssuePubHash: HashCodeString,
+): Uint8Array {
+ const hashContext = createHashContext();
+ hashContext.update(decodeCrock(tokenIssuePubHash));
+ hashTokenEvInner(tokenEv, hashContext);
+ return hashContext.finish();
+}
+
+export function hashTokenEvInner(
+ tokenEv: TokenEnvelope,
+ hashState: TalerHashState,
+): void {
+ const hashInputBuf = new ArrayBuffer(4);
+ const uint8ArrayBuf = new Uint8Array(hashInputBuf);
+ const dv = new DataView(hashInputBuf);
+ dv.setUint32(0, DenomKeyType.toIntTag(tokenEv.cipher));
+ hashState.update(uint8ArrayBuf);
+ switch (tokenEv.cipher) {
+ case DenomKeyType.Rsa:
+ hashState.update(decodeCrock(tokenEv.rsa_blinded_planchet));
+ return;
+ default:
+ throw new Error();
+ }
+}
+
+export function hashPayWalletData(
+ walletData: PayWalletData
+): Uint8Array {
+ const canon = canonicalJson(walletData) + "\0";
+ const bytes = stringToBytes(canon);
+ return hash(bytes);
+}
+
export function eddsaSign(msg: Uint8Array, eddsaPriv: Uint8Array): Uint8Array {
if (tart) {
return tart.eddsaSign(msg, eddsaPriv);
@@ -1062,6 +1125,7 @@ export enum TalerSignaturePurpose {
WALLET_ACCOUNT_MERGE = 1214,
WALLET_PURSE_ECONTRACT = 1216,
WALLET_PURSE_DELETE = 1220,
+ WALLET_TOKEN_USE = 1222,
WALLET_COIN_HISTORY = 1209,
EXCHANGE_CONFIRM_RECOUP = 1039,
EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -30,9 +30,12 @@ import {
import {
AccessToken,
AccountLimit,
+ DenomKeyType,
ExchangeWireAccount,
ObjectCodec,
PaytoString,
+ TalerPreciseTimestamp,
+ UnblindedSignature,
assertUnreachable,
buildCodecForUnion,
codecForAccountLimit,
@@ -42,6 +45,7 @@ import {
codecForExchangeWireAccount,
codecForMap,
codecForPaytoString,
+ codecForPreciseTimestamp,
codecForTalerUriString,
} from "./index.js";
import {
@@ -57,6 +61,7 @@ import {
ClaimToken,
CoinPublicKey,
Cs25519Point,
+ Cs25519Scalar,
CurrencySpecification,
EddsaPublicKey,
EddsaPublicKeyString,
@@ -94,9 +99,50 @@ export interface Proposal {
sig: string;
}
+export type TokenEnvelope = TokenEnvelopeRsa | TokenEnvelopeCs;
+
+export interface TokenEnvelopeRsa {
+ cipher: DenomKeyType.Rsa;
+ rsa_blinded_planchet: string;
+}
+
+export interface TokenEnvelopeCs {
+ cipher: DenomKeyType.ClauseSchnorr;
+ cs_nonce: string;
+ cs_blinded_c0: string; // Crockford base32 encoded
+ cs_blinded_c1: string; // Crockford base32 encoded
+}
+
+export interface SignedTokenEnvelope {
+ blind_sig: TokenIssueBlindSig;
+}
+
+export type TokenIssueBlindSig =
+| RSATokenIssueBlindSig
+| CSTokenIssueBlindSig;
+
+export interface RSATokenIssueBlindSig {
+ cipher: DenomKeyType.Rsa;
+ blinded_rsa_signature: string;
+}
+
+export interface CSTokenIssueBlindSig {
+ cipher: DenomKeyType.ClauseSchnorr;
+ b: Integer;
+ s: Cs25519Scalar;
+}
+
+export interface TokenUseSig {
+ token_sig: EddsaSignatureString;
+ token_pub: EddsaPublicKeyString;
+ ub_sig: UnblindedSignature;
+ h_issue: string;
+}
+
export interface MerchantPayResponse {
sig: string;
pos_confirmation?: string;
+ token_sigs?: SignedTokenEnvelope[];
}
interface MerchantOrderStatusPaid {
@@ -158,7 +204,7 @@ export const codecForMerchantRefundPermission =
export const codecForProposal = (): Codec<Proposal> =>
buildCodecForObject<Proposal>()
- .property("contract_terms", codecForAny())
+ .property("contract_terms", codecForMerchantContractTerms())
.property("sig", codecForString())
.build("Proposal");
@@ -249,8 +295,34 @@ export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
buildCodecForObject<MerchantPayResponse>()
.property("sig", codecForString())
.property("pos_confirmation", codecOptional(codecForString()))
+ .property("token_sigs", codecOptional(codecForList(codecForSignedTokenEnvelope())))
.build("MerchantPayResponse");
+export const codecForSignedTokenEnvelope = (): Codec<SignedTokenEnvelope> =>
+ buildCodecForObject<SignedTokenEnvelope>()
+ .property("blind_sig", codecForTokenIssueBlindSig())
+ .build("SignedTokenEnvelope");
+
+export const codecForTokenIssueBlindSig = (): Codec<TokenIssueBlindSig> =>
+ buildCodecForUnion<TokenIssueBlindSig>()
+ .discriminateOn("cipher")
+ .alternative(DenomKeyType.Rsa, codecForRSATokenIssueBlindSig())
+ .alternative(DenomKeyType.ClauseSchnorr, codecForCsTokenIssueBlindSig())
+ .build("TokenIssueBlindSig");
+
+export const codecForRSATokenIssueBlindSig = (): Codec<RSATokenIssueBlindSig> =>
+ buildCodecForObject<RSATokenIssueBlindSig>()
+ .property("cipher", codecForConstString(DenomKeyType.Rsa))
+ .property("blinded_rsa_signature", codecForString())
+ .build("RSATokenIssueBlindSig");
+
+export const codecForCsTokenIssueBlindSig = (): Codec<CSTokenIssueBlindSig> =>
+ buildCodecForObject<CSTokenIssueBlindSig>()
+ .property("cipher", codecForConstString(DenomKeyType.ClauseSchnorr))
+ .property("b", codecForNumber())
+ .property("s", codecForString())
+ .build("CSTokenIssueBlindSig");
+
export const codecForMerchantOrderStatusPaid =
(): Codec<MerchantOrderStatusPaid> =>
buildCodecForObject<MerchantOrderStatusPaid>()
@@ -456,7 +528,6 @@ export interface Product {
/**
* Contract terms from a merchant.
- * FIXME: Add type field!
*/
interface MerchantContractTermsCommon {
// The hash of the merchant instance's wire details.
@@ -593,8 +664,13 @@ interface MerchantContractTermsCommon {
minimum_age?: Integer;
}
+export enum MerchantContractVersion {
+ V0 = 0,
+ V1 = 1,
+}
+
export interface MerchantContractTermsV0 extends MerchantContractTermsCommon {
- version?: 0;
+ version?: MerchantContractVersion.V0;
// Total price for the transaction.
// The exchange will subtract deposit fees from that amount
@@ -607,7 +683,7 @@ export interface MerchantContractTermsV0 extends MerchantContractTermsCommon {
}
export interface MerchantContractTermsV1 extends MerchantContractTermsCommon {
- version: 1;
+ version: MerchantContractVersion.V1;
// List of contract choices that the customer can select from.
// @since protocol **vSUBSCRIBE**
@@ -643,10 +719,14 @@ export interface MerchantContractChoice {
max_fee: AmountString;
}
+export enum MerchantContractInputType {
+ Token = "token",
+}
+
export type MerchantContractInput = MerchantContractInputToken;
export interface MerchantContractInputToken {
- type: "token";
+ type: MerchantContractInputType.Token;
// Slug of the token family in the
// token_families map on the order.
@@ -657,12 +737,17 @@ export interface MerchantContractInputToken {
count?: Integer;
}
+export enum MerchantContractOutputType {
+ Token = "token",
+ TaxReceipt = "tax-receipt",
+}
+
export type MerchantContractOutput =
| MerchantContractOutputToken
| MerchantContractOutputTaxReceipt;
export interface MerchantContractOutputToken {
- type: "token";
+ type: MerchantContractOutputType.Token;
// Slug of the token family in the
// 'token_families' map on the top-level.
@@ -678,7 +763,7 @@ export interface MerchantContractOutputToken {
}
export interface MerchantContractOutputTaxReceipt {
- type: "tax-receipt";
+ type: MerchantContractOutputType.TaxReceipt;
// Array of base URLs of donation authorities that can be
// used to issue the tax receipts. The client must select one.
@@ -741,12 +826,17 @@ export interface TokenIssueCsPublicKey {
signature_validity_end: Timestamp;
}
+export enum MerchantContractTokenKind {
+ Subscription = "subscription",
+ Discount = "discount",
+}
+
export type MerchantContractTokenDetails =
| MerchantContractSubscriptionTokenDetails
| MerchantContractDiscountTokenDetails;
export interface MerchantContractSubscriptionTokenDetails {
- class: "subscription";
+ class: MerchantContractTokenKind.Subscription;
// Array of domain names where this subscription
// can be safely used (e.g. the issuer warrants that
@@ -757,7 +847,7 @@ export interface MerchantContractSubscriptionTokenDetails {
}
export interface MerchantContractDiscountTokenDetails {
- class: "discount";
+ class: MerchantContractTokenKind.Discount;
// Array of domain names where this discount token
// is intended to be used. May contain "*" for any
@@ -897,7 +987,7 @@ const codecForMerchantContractTermsCommon =
export const codecForMerchantContractTermsV0 =
(): Codec<MerchantContractTermsV0> =>
buildCodecForObject<MerchantContractTermsV0>()
- .property("version", codecOptional(codecForConstNumber(0)))
+ .property("version", codecOptional(codecForConstNumber(MerchantContractVersion.V0)))
.property("amount", codecForAmountString())
.property("max_fee", codecForAmountString())
.mixin(codecForMerchantContractTermsCommon())
@@ -906,7 +996,7 @@ export const codecForMerchantContractTermsV0 =
export const codecForMerchantContractTermsV1 =
(): Codec<MerchantContractTermsV1> =>
buildCodecForObject<MerchantContractTermsV1>()
- .property("version", codecForConstNumber(1))
+ .property("version", codecForConstNumber(MerchantContractVersion.V1))
.property("choices", codecForList(codecForMerchantContractChoice()))
.property(
"token_families",
@@ -919,8 +1009,8 @@ export const codecForMerchantContractTerms = (): Codec<MerchantContractTerms> =>
buildCodecForUnion<MerchantContractTerms>()
.discriminateOn("version")
.alternative(undefined, codecForMerchantContractTermsV0())
- .alternative(0, codecForMerchantContractTermsV0())
- .alternative(1, codecForMerchantContractTermsV1())
+ .alternative(MerchantContractVersion.V0, codecForMerchantContractTermsV0())
+ .alternative(MerchantContractVersion.V1, codecForMerchantContractTermsV1())
.build("TalerMerchantApi.ContractTerms");
export const codecForMerchantContractChoice =
@@ -935,13 +1025,13 @@ export const codecForMerchantContractChoice =
export const codecForMerchantContractInput = (): Codec<MerchantContractInput> =>
buildCodecForUnion<MerchantContractInput>()
.discriminateOn("type")
- .alternative("token", codecForMerchantContractInputToken())
+ .alternative(MerchantContractInputType.Token, codecForMerchantContractInputToken())
.build("TalerMerchantApi.ContractInput");
export const codecForMerchantContractInputToken =
(): Codec<MerchantContractInputToken> =>
buildCodecForObject<MerchantContractInputToken>()
- .property("type", codecForConstString("token"))
+ .property("type", codecForConstString(MerchantContractInputType.Token))
.property("token_family_slug", codecForString())
.property("count", codecOptional(codecForNumber()))
.build("TalerMerchantApi.ContractInputToken");
@@ -950,14 +1040,20 @@ export const codecForMerchantContractOutput =
(): Codec<MerchantContractOutput> =>
buildCodecForUnion<MerchantContractOutput>()
.discriminateOn("type")
- .alternative("token", codecForMerchantContractOutputToken())
- .alternative("tax-receipt", codecForMerchantContractOutputTaxReceipt())
+ .alternative(
+ MerchantContractOutputType.Token,
+ codecForMerchantContractOutputToken()
+ )
+ .alternative(
+ MerchantContractOutputType.TaxReceipt,
+ codecForMerchantContractOutputTaxReceipt()
+ )
.build("TalerMerchantApi.ContractOutput");
export const codecForMerchantContractOutputToken =
(): Codec<MerchantContractOutputToken> =>
buildCodecForObject<MerchantContractOutputToken>()
- .property("type", codecForConstString("token"))
+ .property("type", codecForConstString(MerchantContractOutputType.Token))
.property("token_family_slug", codecForString())
.property("count", codecOptional(codecForNumber()))
.property("key_index", codecForNumber())
@@ -966,7 +1062,7 @@ export const codecForMerchantContractOutputToken =
export const codecForMerchantContractOutputTaxReceipt =
(): Codec<MerchantContractOutputTaxReceipt> =>
buildCodecForObject<MerchantContractOutputTaxReceipt>()
- .property("type", codecForConstString("tax-receipt"))
+ .property("type", codecForConstString(MerchantContractOutputType.TaxReceipt))
.property("donau_urls", codecForList(codecForString()))
.property("amount", codecOptional(codecForAmountString()))
.build("TalerMerchantApi.ContractOutputTaxReceipt");
@@ -1014,23 +1110,26 @@ export const codecForMerchantContractTokenDetails =
buildCodecForUnion<MerchantContractTokenDetails>()
.discriminateOn("class")
.alternative(
- "subscription",
+ MerchantContractTokenKind.Subscription,
codecForMerchantContractSubscriptionTokenDetails(),
)
- .alternative("discount", codecForMerchantContractDiscountTokenDetails())
+ .alternative(
+ MerchantContractTokenKind.Discount,
+ codecForMerchantContractDiscountTokenDetails()
+ )
.build("TalerMerchantApi.ContractTokenDetails");
export const codecForMerchantContractSubscriptionTokenDetails =
(): Codec<MerchantContractSubscriptionTokenDetails> =>
buildCodecForObject<MerchantContractSubscriptionTokenDetails>()
- .property("class", codecForConstString("subscription"))
+ .property("class", codecForConstString(MerchantContractTokenKind.Subscription))
.property("trusted_domains", codecForList(codecForString()))
.build("TalerMerchantApi.ContractSubscriptionTokenDetails");
export const codecForMerchantContractDiscountTokenDetails =
(): Codec<MerchantContractDiscountTokenDetails> =>
buildCodecForObject<MerchantContractDiscountTokenDetails>()
- .property("class", codecForConstString("discount"))
+ .property("class", codecForConstString(MerchantContractTokenKind.Discount))
.property("expected_domains", codecForList(codecForString()))
.build("TalerMerchantApi.ContractDiscountTokenDetails");
@@ -1100,7 +1199,7 @@ export interface ClaimRequest {
export interface ClaimResponse {
// Contract terms of the claimed order
- contract_terms: ContractTerms;
+ contract_terms: MerchantContractTerms;
// Signature by the merchant over the contract terms.
sig: EddsaSignatureString;
@@ -2190,7 +2289,9 @@ export interface PostOrderRequest {
otp_id?: string;
}
-export type Order = MinimalOrderDetail & Partial<ContractTerms>;
+export type Order =
+ | OrderV0
+ | OrderV1;
export interface MinimalOrderDetail {
// Amount to be paid by the customer.
@@ -2313,7 +2414,8 @@ export interface CheckPaymentPaidResponse {
refund_amount: AmountString;
// Contract terms.
- contract_terms: ContractTerms;
+ // FIXME: support for contract v1
+ contract_terms: MerchantContractTerms;
// The wire transfer status from the exchange for this order if
// available, otherwise empty array.
@@ -2337,7 +2439,7 @@ export interface CheckPaymentClaimedResponse {
order_status: "claimed";
// Contract terms.
- contract_terms: ContractTerms;
+ contract_terms: MerchantContractTerms;
// Status URL, can be used as a redirect target for the browser
// to show the order QR code / trigger the wallet.
@@ -2815,6 +2917,20 @@ export interface TokenFamilyCreateRequest {
// Validity duration of an issued token.
duration: RelativeTime;
+ // Rounding granularity for the start validity of keys.
+ // The desired time is rounded down to a multiple of this
+ // granularity and then the start_offset is added to
+ // compute the actual start time of the token keys' validity.
+ // The end is then computed by adding the duration.
+ // Must be 1 minute, 1 hour, 1 day, 1 week, 30 days, 90 days
+ // or 365 days (1 year).
+ validity_granularity: RelativeTime;
+
+ // Offset to add to the start time rounded to validity_granularity
+ // to compute the actual start time for a key.
+ // Default is zero.
+ start_offset?: Integer;
+
// Kind of the token family.
kind: TokenFamilyKind;
}
@@ -2899,7 +3015,83 @@ export interface TokenFamilyDetails {
// How many tokens have been redeemed for this family.
redeemed: Integer;
}
-export interface ContractTerms {
+
+export interface OrderChoice {
+ // Total price for the choice. The exchange will subtract deposit
+ // fees from that amount before transferring it to the merchant.
+ amount: AmountString;
+
+ // Inputs that must be provided by the customer, if this choice is selected.
+ // Defaults to empty array if not specified.
+ inputs?: OrderInput[];
+
+ // Outputs provided by the merchant, if this choice is selected.
+ // Defaults to empty array if not specified.
+ outputs?: OrderOutput[];
+
+ // Maximum total deposit fee accepted by the merchant for this contract.
+ // Overrides defaults of the merchant instance.
+ max_fee?: AmountString;
+}
+
+export enum OrderInputType {
+ Token = "token",
+}
+
+export type OrderInput =
+ | OrderInputToken;
+
+export interface OrderInputToken {
+ // Token input.
+ type: OrderInputType.Token;
+
+ // Token family slug as configured in the merchant backend. Slug is unique
+ // across all configured tokens of a merchant.
+ token_family_slug: string;
+
+ // How many units of the input are required.
+ // Defaults to 1 if not specified. Output with count == 0 are ignored by
+ // the merchant backend.
+ count?: Integer;
+}
+
+export enum OrderOutputType {
+ Token = "token",
+ TaxReceipt = "tax-receipt",
+}
+
+export type OrderOutput =
+ | OrderOutputToken
+ | OrderOutputTaxReceipt;
+
+export interface OrderOutputToken {
+ type: OrderOutputType.Token;
+
+ // Token family slug as configured in the merchant backend. Slug is unique
+ // across all configured tokens of a merchant.
+ token_family_slug: string;
+
+ // How many units of the output are issued by the merchant.
+ // Defaults to 1 if not specified. Output with count == 0 are ignored by
+ // the merchant backend.
+ count?: Integer;
+
+ // When should the output token be valid. Can be specified if the
+ // desired validity period should be in the future (like selling
+ // a subscription for the next month). Optional. If not given,
+ // the validity is supposed to be "now" (time of order creation).
+ valid_at?: TalerPreciseTimestamp;
+}
+
+export interface OrderOutputTaxReceipt {
+ type: OrderOutputType.TaxReceipt;
+
+ // Total amount that will be on the tax receipt.
+ // Optional, if missing the full amount will be on the receipt.
+ amount?: AmountString;
+}
+
+export interface OrderCommon {
// Human-readable description of the whole purchase.
summary: string;
@@ -2912,12 +3104,7 @@ export interface ContractTerms {
// before the customer paid for them, the order_id can be used
// by the frontend to restore a proposal from the information
// encoded in it (such as a short product identifier and timestamp).
- order_id: string;
-
- // Total price for the transaction.
- // The exchange will subtract deposit fees from that amount
- // before transferring it to the merchant.
- amount: AmountString;
+ order_id?: string;
// URL where the same contract could be ordered again (if
// available). Returned also at the public order endpoint
@@ -2943,48 +3130,31 @@ export interface ContractTerms {
// messages.
fulfillment_message_i18n?: { [lang_tag: string]: string };
- // Maximum total deposit fee accepted by the merchant for this contract.
- // Overrides defaults of the merchant instance.
- max_fee: AmountString;
-
// List of products that are part of the purchase (see Product).
- products: Product[];
+ products?: Product[];
// Time when this contract was generated.
- timestamp: Timestamp;
+ timestamp?: Timestamp;
// After this deadline has passed, no refunds will be accepted.
- refund_deadline: Timestamp;
+ refund_deadline?: Timestamp;
// After this deadline, the merchant won't accept payments for the contract.
- pay_deadline: Timestamp;
+ pay_deadline?: Timestamp;
// Transfer deadline for the exchange. Must be in the
// deposit permissions of coins used to pay for this order.
- wire_transfer_deadline: Timestamp;
-
- // Merchant's public key used to sign this proposal; this information
- // is typically added by the backend. Note that this can be an ephemeral key.
- merchant_pub: EddsaPublicKey;
+ wire_transfer_deadline?: Timestamp;
// Base URL of the (public!) merchant backend API.
// Must be an absolute URL that ends with a slash.
- merchant_base_url: string;
-
- // More info about the merchant, see below.
- merchant: Merchant;
-
- // The hash of the merchant instance's wire details.
- h_wire: HashCode;
+ merchant_base_url?: string;
// Wire transfer method identifier for the wire method associated with h_wire.
// The wallet may only select exchanges via a matching auditor if the
// exchange also supports this wire method.
// The wire transfer fees must be added based on this wire transfer method.
- wire_method: string;
-
- // Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
- exchanges: Exchange[];
+ wire_method?: string;
// Delivery location for (all!) products.
delivery_location?: Location;
@@ -2993,10 +3163,6 @@ export interface ContractTerms {
// May be overwritten by individual products.
delivery_date?: Timestamp;
- // Nonce generated by the wallet and echoed by the merchant
- // in this field when the proposal is generated.
- nonce: string;
-
// Specifies for how long the wallet should try to get an
// automatic refund for the purchase. If this field is
// present, the wallet should wait for a few seconds after
@@ -3021,14 +3187,39 @@ export interface ContractTerms {
// contract without storing it separately in their database.
extra?: any;
- // Minimum age the buyer must have (in years). Default is 0.
- // This value is at least as large as the maximum over all
- // minimum age requirements of the products in this contract.
- // It might also be set independent of any product, due to
- // legal requirements.
+ // Minimum age buyer must have (in years). Default is 0.
minimum_age?: Integer;
}
+export enum OrderVersion {
+ V0 = 0,
+ V1 = 1,
+}
+
+export interface OrderV0 extends OrderCommon {
+ // Optional, defaults to 0 if not set.
+ version?: OrderVersion.V0;
+
+ // Total price for the transaction. The exchange will subtract deposit
+ // fees from that amount before transferring it to the merchant.
+ amount: AmountString;
+
+ // Maximum total deposit fee accepted by the merchant for this contract.
+ // Overrides defaults of the merchant instance.
+ max_fee?: AmountString;
+}
+
+export interface OrderV1 extends OrderCommon {
+ // Version 1 order support discounts and subscriptions.
+ // https://docs.taler.net/design-documents/046-mumimo-contracts.html
+ // @since protocol **vSUBSCRIBE**
+ version: OrderVersion.V1,
+
+ // List of contract choices that the customer can select from.
+ // @since protocol **vSUBSCRIBE**
+ choices?: OrderChoice[];
+}
+
export interface Product {
// Merchant-internal identifier for the product.
product_id?: string;
@@ -3140,6 +3331,12 @@ export interface Exchange {
// Master public key of the exchange.
master_pub: EddsaPublicKey;
+
+ // Maximum amount that the merchant could be paid
+ // using this exchange (due to legal limits).
+ // New in protocol **v17**.
+ // Optional, no limit if missing.
+ max_contribution?: AmountString;
}
export interface MerchantReserveCreateConfirmation {
@@ -3224,7 +3421,7 @@ export const codecForTalerMerchantConfigResponse =
export const codecForClaimResponse = (): Codec<ClaimResponse> =>
buildCodecForObject<ClaimResponse>()
- .property("contract_terms", codecForContractTerms())
+ .property("contract_terms", codecForMerchantContractTerms())
.property("sig", codecForString())
.build("TalerMerchantApi.ClaimResponse");
@@ -3590,38 +3787,97 @@ export const codecForExchange = (): Codec<Exchange> =>
.property("master_pub", codecForString())
.property("priority", codecForNumber())
.property("url", codecForString())
+ .property("max_contribution", codecOptional(codecForAmountString()))
.build("TalerMerchantApi.Exchange");
-export const codecForContractTerms = (): Codec<ContractTerms> =>
- buildCodecForObject<ContractTerms>()
- .property("order_id", codecForString())
+const codecForOrderCommon = (): ObjectCodec<OrderCommon> =>
+ buildCodecForObject<Order>()
+ .property("order_id", codecOptional(codecForString()))
+ .property("public_reorder_url", codecOptional(codecForString()))
.property("fulfillment_url", codecOptional(codecForString()))
.property("fulfillment_message", codecOptional(codecForString()))
.property(
"fulfillment_message_i18n",
codecOptional(codecForInternationalizedString()),
)
- .property("merchant_base_url", codecForString())
- .property("h_wire", codecForString())
+ .property("merchant_base_url", codecOptional(codecForString()))
.property("auto_refund", codecOptional(codecForDuration))
- .property("wire_method", codecForString())
.property("summary", codecForString())
.property("summary_i18n", codecOptional(codecForInternationalizedString()))
- .property("nonce", codecForString())
- .property("amount", codecForAmountString())
- .property("pay_deadline", codecForTimestamp)
- .property("refund_deadline", codecForTimestamp)
- .property("wire_transfer_deadline", codecForTimestamp)
- .property("timestamp", codecForTimestamp)
+ .property("pay_deadline", codecOptional(codecForTimestamp))
+ .property("refund_deadline", codecOptional(codecForTimestamp))
+ .property("wire_transfer_deadline", codecOptional(codecForTimestamp))
+ .property("timestamp", codecOptional(codecForTimestamp))
.property("delivery_location", codecOptional(codecForLocation()))
.property("delivery_date", codecOptional(codecForTimestamp))
- .property("max_fee", codecForAmountString())
- .property("merchant", codecForMerchant())
- .property("merchant_pub", codecForString())
- .property("exchanges", codecForList(codecForExchange()))
- .property("products", codecForList(codecForProduct()))
- .property("extra", codecForAny())
- .build("TalerMerchantApi.ContractTerms");
+ .property("products", codecOptional(codecForList(codecForProduct())))
+ .property("extra", codecOptional(codecForAny()))
+ .build("TalerMerchantApi.Order");
+
+export const codecForOrderV0 = (): Codec<OrderV0> =>
+ buildCodecForObject<OrderV0>()
+ .property("version", codecOptional(codecForConstNumber(OrderVersion.V0)))
+ .property("amount", codecForAmountString())
+ .property("max_fee", codecOptional(codecForAmountString()))
+ .mixin(codecForOrderCommon())
+ .build("TalerMerchantApi.OrderV0");
+
+export const codecForOrderV1 = (): Codec<OrderV1> =>
+ buildCodecForObject<OrderV1>()
+ .property("version", codecForConstNumber(OrderVersion.V1))
+ .property("choices", codecForList(codecForOrderChoice()))
+ .mixin(codecForOrderCommon())
+ .build("TalerMerchantApi.OrderV1");
+
+export const codecForOrder = (): Codec<Order> =>
+ buildCodecForUnion<Order>()
+ .discriminateOn("version")
+ .alternative(undefined, codecForOrderV0())
+ .alternative(OrderVersion.V0, codecForOrderV0())
+ .alternative(OrderVersion.V1, codecForOrderV1())
+ .build("TalerMerchantApi.Order");
+
+export const codecForOrderChoice = (): Codec<OrderChoice> =>
+ buildCodecForObject<OrderChoice>()
+ .property("amount", codecForAmountString())
+ .property("max_fee", codecOptional(codecForAmountString()))
+ .property("inputs", codecOptional(codecForList(codecForOrderInput())))
+ .property("outputs", codecOptional(codecForList(codecForOrderOutput())))
+ .build("TalerMerchantApi.OrderChoice");
+
+export const codecForOrderInput = (): Codec<OrderInput> =>
+ buildCodecForUnion<OrderInput>()
+ .discriminateOn("type")
+ .alternative(OrderInputType.Token, codecForOrderInputToken())
+ .build("TalerMerchantApi.OrderInput");
+
+export const codecForOrderInputToken = (): Codec<OrderInputToken> =>
+ buildCodecForObject<OrderInputToken>()
+ .property("type", codecForConstString(OrderInputType.Token))
+ .property("token_family_slug", codecForString())
+ .property("count", codecOptional(codecForNumber()))
+ .build("TalerMerchantApi.OrderInputToken");
+
+export const codecForOrderOutput = (): Codec<OrderOutput> =>
+ buildCodecForUnion<OrderOutput>()
+ .discriminateOn("type")
+ .alternative(OrderOutputType.Token, codecForOrderOutputToken())
+ .alternative(OrderOutputType.TaxReceipt, codecForOrderOutputTaxReceipt())
+ .build("TalerMerchantApi.OrderOutput");
+
+export const codecForOrderOutputToken = (): Codec<OrderOutputToken> =>
+ buildCodecForObject<OrderOutputToken>()
+ .property("type", codecForConstString(OrderOutputType.Token))
+ .property("token_family_slug", codecForString())
+ .property("count", codecOptional(codecForNumber()))
+ .property("valid_at", codecOptional(codecForPreciseTimestamp))
+ .build("TalerMerchantApi.OrderOutputToken");
+
+export const codecForOrderOutputTaxReceipt = (): Codec<OrderOutputTaxReceipt> =>
+ buildCodecForObject<OrderOutputTaxReceipt>()
+ .property("type", codecForConstString(OrderOutputType.TaxReceipt))
+ .property("amount", codecOptional(codecForAmountString()))
+ .build("TalerMerchantApi.OrderOutputTaxReceipt");
export const codecForProduct = (): Codec<Product> =>
buildCodecForObject<Product>()
@@ -3650,7 +3906,7 @@ export const codecForCheckPaymentPaidResponse =
.property("exchange_code", codecForNumber())
.property("exchange_http_status", codecForNumber())
.property("refund_amount", codecForAmountString())
- .property("contract_terms", codecForContractTerms())
+ .property("contract_terms", codecForMerchantContractTerms())
.property("wire_reports", codecForList(codecForTransactionWireReport()))
.property("wire_details", codecForList(codecForTransactionWireTransfer()))
.property("refund_details", codecForList(codecForRefundDetails()))
@@ -3674,7 +3930,7 @@ export const codecForCheckPaymentClaimedResponse =
(): Codec<CheckPaymentClaimedResponse> =>
buildCodecForObject<CheckPaymentClaimedResponse>()
.property("order_status", codecForConstString("claimed"))
- .property("contract_terms", codecForContractTerms())
+ .property("contract_terms", codecForMerchantContractTerms())
.property("order_status_url", codecForString())
.build("TalerMerchantApi.CheckPaymentClaimedResponse");
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -50,7 +50,7 @@ import {
codecForInternationalizedString,
} from "./types-taler-common.js";
import {
- ContractTerms,
+ MerchantContractTerms,
MerchantInfo,
codecForMerchantInfo,
} from "./types-taler-merchant.js";
@@ -650,7 +650,7 @@ export interface TransactionPayment extends TransactionCommon {
*
* Only included if explicitly included in the request.
*/
- contractTerms?: ContractTerms;
+ contractTerms?: MerchantContractTerms;
/**
* Amount that must be paid for the contract
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -51,7 +51,6 @@ import {
AmountString,
CurrencySpecification,
EddsaPrivateKeyString,
- InternationalizedString,
TalerMerchantApi,
TemplateParams,
WithdrawalOperationStatusFlag,
@@ -85,8 +84,13 @@ import {
codecForPeerContractTerms,
} from "./types-taler-exchange.js";
import {
+ MerchantContractChoice,
+ MerchantContractTerms,
MerchantContractTermsV0,
- MerchantInfo,
+ MerchantContractTermsV1,
+ TokenEnvelope,
+ TokenIssuePublicKey,
+ codecForMerchantContractTerms,
codecForMerchantContractTermsV0,
} from "./types-taler-merchant.js";
import { BackupRecovery } from "./types-taler-sync.js";
@@ -725,6 +729,7 @@ export enum PreparePayResultType {
PaymentPossible = "payment-possible",
InsufficientBalance = "insufficient-balance",
AlreadyConfirmed = "already-confirmed",
+ ChoiceSelection = "choice-selection",
}
export const codecForPreparePayResultPaymentPossible =
@@ -892,7 +897,7 @@ export const codecForPreparePayResultInsufficientBalance =
(): Codec<PreparePayResultInsufficientBalance> =>
buildCodecForObject<PreparePayResultInsufficientBalance>()
.property("amountRaw", codecForAmountString())
- .property("contractTerms", codecForAny())
+ .property("contractTerms", codecForMerchantContractTermsV0())
.property("talerUri", codecForString())
.property("transactionId", codecForTransactionIdStr())
.property(
@@ -918,11 +923,24 @@ export const codecForPreparePayResultAlreadyConfirmed =
.property("scopes", codecForList(codecForScopeInfo()))
.property("paid", codecForBoolean())
.property("talerUri", codecForString())
- .property("contractTerms", codecForAny())
+ .property("contractTerms", codecForMerchantContractTermsV0())
.property("contractTermsHash", codecForString())
.property("transactionId", codecForTransactionIdStr())
.build("PreparePayResultAlreadyConfirmed");
+export const codecForPreparePayResultChoiceSelection =
+ (): Codec<PreparePayResultChoiceSelection> =>
+ buildCodecForObject<PreparePayResultChoiceSelection>()
+ .property(
+ "status",
+ codecForConstString(PreparePayResultType.ChoiceSelection),
+ )
+ .property("transactionId", codecForTransactionIdStr())
+ .property("contractTerms", codecForMerchantContractTerms())
+ .property("contractTermsHash", codecForString())
+ .property("talerUri", codecForString())
+ .build("PreparePayResultChoiceSelection");
+
export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
buildCodecForUnion<PreparePayResult>()
.discriminateOn("status")
@@ -938,6 +956,10 @@ export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
PreparePayResultType.PaymentPossible,
codecForPreparePayResultPaymentPossible(),
)
+ .alternative(
+ PreparePayResultType.ChoiceSelection,
+ codecForPreparePayResultChoiceSelection(),
+ )
.build("PreparePayResult");
/**
@@ -946,7 +968,8 @@ export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
export type PreparePayResult =
| PreparePayResultInsufficientBalance
| PreparePayResultAlreadyConfirmed
- | PreparePayResultPaymentPossible;
+ | PreparePayResultPaymentPossible
+ | PreparePayResultChoiceSelection;
/**
* Payment is possible.
@@ -1022,6 +1045,21 @@ export interface PreparePayResultAlreadyConfirmed {
talerUri: string;
}
+/**
+ * Unconfirmed contract v1 payment.
+ */
+export interface PreparePayResultChoiceSelection {
+ status: PreparePayResultType.ChoiceSelection;
+
+ transactionId: TransactionIdStr;
+
+ contractTerms: MerchantContractTerms;
+
+ contractTermsHash: string;
+
+ talerUri: string;
+}
+
export interface BankWithdrawDetails {
status: WithdrawalOperationStatusFlag;
currency: string;
@@ -1104,6 +1142,41 @@ export interface PlanchetCreationRequest {
}
/**
+ * Minimal information needed about a slate for unblinding a signature.
+ */
+export interface SlateUnblindInfo {
+ tokenIssuePub: TokenIssuePublicKey;
+ blindingKey: string;
+}
+
+export interface Slate {
+ tokenPub: string;
+ tokenPriv: string;
+ tokenIssuePub: TokenIssuePublicKey;
+ tokenIssuePubHash: string;
+ tokenWalletData: PayWalletData;
+ tokenEv: TokenEnvelope;
+ tokenEvHash: string;
+ blindingKey: string;
+}
+
+export interface SlateCreationRequest {
+ secretSeed: string;
+ choiceIndex: number;
+ outputIndex: number;
+ tokenIssuePub: TokenIssuePublicKey;
+ genTokenUseSig: boolean;
+ contractTerms: MerchantContractTermsV1;
+ contractTermsHash: string;
+}
+
+export interface SignTokenUseRequest {
+ tokenUsePriv: string;
+ walletDataHash: string;
+ contractTermsHash: string;
+}
+
+/**
* Reasons for why a coin is being refreshed.
*/
export enum RefreshReason {
@@ -1152,6 +1225,8 @@ export interface DepositInfo {
requiredMinimumAge?: number;
ageCommitmentProof?: AgeCommitmentProof;
+
+ walletDataHash?: string;
}
export interface ExchangesShortListResponse {
@@ -2214,6 +2289,67 @@ export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
.property("talerPayUri", codecForString())
.build("PreparePay");
+export interface GetChoicesForPaymentRequest {
+ transactionId: string;
+}
+
+export const codecForGetChoicesForPaymentRequest =
+ (): Codec<GetChoicesForPaymentRequest> =>
+ buildCodecForObject<GetChoicesForPaymentRequest>()
+ .property("transactionId", codecForString())
+ .build("GetChoicesForPaymentRequest");
+
+export enum ChoiceSelectionDetailType {
+ PaymentPossible = "payment-possible",
+ InsufficientBalance = "insufficient-balance",
+}
+
+export type ChoiceSelectionDetail =
+ | ChoiceSelectionDetailPaymentPossible
+ | ChoiceSelectionDetailInsufficientBalance;
+
+export interface ChoiceSelectionDetailPaymentPossible {
+ status: ChoiceSelectionDetailType.PaymentPossible;
+}
+
+export interface ChoiceSelectionDetailInsufficientBalance {
+ status: ChoiceSelectionDetailType.InsufficientBalance;
+ balanceDetail: PaymentInsufficientBalanceDetails;
+}
+
+export type GetChoicesForPaymentResult = {
+ /**
+ * Details for all choices in the contract.
+ *
+ * The index in this array corresponds to the choice
+ * index in the original contract v1. For contract v0
+ * orders, it will only contain a single choice with no
+ * inputs/outputs.
+ */
+ choices: ChoiceSelectionDetail[];
+
+ /**
+ * Index of the choice in @e choices array to present
+ * to the user as default.
+ *
+ * Won´t be set if no default selection is configured,
+ * otherwise, it will always be 0 for v0 orders.
+ */
+ defaultChoiceIndex?: number;
+
+ /**
+ * Whether the default choice or the only
+ * choice should be executed automatically without
+ * user confirmation.
+ *
+ * If true, the wallet should call `confirmPay'
+ * immediately afterwards, if false, the user
+ * should be first prompted to select and
+ * confirm a choice.
+ */
+ automaticExecution: boolean;
+}
+
export interface SharePaymentRequest {
merchantBaseUrl: string;
orderId: string;
@@ -2263,6 +2399,19 @@ export interface ConfirmPayRequest {
transactionId: TransactionIdStr;
sessionId?: string;
forcedCoinSel?: ForcedCoinSel;
+
+ /**
+ * Whether token selection should be forced
+ * e.g. use tokens with non-matching `expected_domains'
+ *
+ * Only applies to v1 orders.
+ */
+ forcedTokenSel?: boolean;
+
+ /**
+ * Only applies to v1 orders.
+ */
+ choiceIndex?: number;
}
export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
@@ -2270,6 +2419,8 @@ export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
.property("transactionId", codecForTransactionIdStr())
.property("sessionId", codecOptional(codecForString()))
.property("forcedCoinSel", codecForAny())
+ .property("forcedTokenSel", codecOptional(codecForBoolean()))
+ .property("choiceIndex", codecOptional(codecForNumber()))
.build("ConfirmPay");
export interface CoreApiRequestEnvelope {
@@ -3365,35 +3516,21 @@ export interface AllowedExchangeInfo {
* Data extracted from the contract terms that is relevant for payment
* processing in the wallet.
*/
-export interface WalletContractData {
+export type WalletContractData = MerchantContractTerms & {
/**
* Fulfillment URL, or the empty string if the order has no fulfillment URL.
*
* Stored as a non-nullable string as we use this field for IndexedDB indexing.
*/
fulfillmentUrl: string;
-
contractTermsHash: string;
- fulfillmentMessage?: string;
- fulfillmentMessageI18n?: InternationalizedString;
merchantSig: string;
- merchantPub: string;
- merchant: MerchantInfo;
- amount: AmountString;
- orderId: string;
- merchantBaseUrl: string;
- summary: string;
- summaryI18n: { [lang_tag: string]: string } | undefined;
- autoRefund: TalerProtocolDuration | undefined;
- payDeadline: TalerProtocolTimestamp;
- refundDeadline: TalerProtocolTimestamp;
- allowedExchanges: AllowedExchangeInfo[];
- timestamp: TalerProtocolTimestamp;
- wireMethod: string;
- wireInfoHash: string;
- maxDepositFee: AmountString;
- minimumAge?: number;
-}
+};
+
+export type PayWalletData = {
+ choice_index?: number;
+ tokens_evs: TokenEnvelope[];
+};
export interface TestingWaitExchangeStateRequest {
exchangeBaseUrl: string;
diff --git a/packages/taler-util/src/types.test.ts b/packages/taler-util/src/types.test.ts
@@ -15,7 +15,7 @@
*/
import test from "ava";
-import { codecForContractTerms, MerchantContractTerms } from "./index.js";
+import { codecForMerchantContractTerms, MerchantContractTerms } from "./index.js";
test("contract terms validation", (t) => {
const c = {
@@ -38,13 +38,13 @@ test("contract terms validation", (t) => {
wire_method: "test",
} satisfies MerchantContractTerms;
- codecForContractTerms().decode(c);
+ codecForMerchantContractTerms().decode(c);
const c1 = JSON.parse(JSON.stringify(c));
c1.pay_deadline = "foo";
try {
- codecForContractTerms().decode(c1);
+ codecForMerchantContractTerms().decode(c1);
} catch (e) {
t.pass();
return;
@@ -83,7 +83,7 @@ test("contract terms validation (locations)", (t) => {
},
} satisfies MerchantContractTerms;
- const r = codecForContractTerms().decode(c);
+ const r = codecForMerchantContractTerms().decode(c);
t.assert(r.merchant.address?.country === "DE");
t.assert(r.delivery_location?.country === "FR");
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -1596,6 +1596,9 @@ advancedCli
case PreparePayResultType.PaymentPossible:
console.log("payment possible");
break;
+ case PreparePayResultType.ChoiceSelection:
+ console.log("choice selection");
+ break;
default:
assertUnreachable(res);
}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -84,6 +84,11 @@ export interface CoinsSpendInfo {
transactionId: TransactionIdStr;
}
+export interface TokensSpendInfo {
+ tokenPubs: string[],
+ transactionId: TransactionIdStr;
+}
+
export async function makeCoinsVisible(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
@@ -256,6 +261,37 @@ export async function spendCoins(
);
}
+export async function spendTokens(
+ tx: WalletDbReadWriteTransaction<
+ [
+ "tokens",
+ "purchases",
+ ]
+ >,
+ tsi: TokensSpendInfo,
+): Promise<void> {
+ if (tsi.tokenPubs.length === 0) {
+ return;
+ }
+
+ for (const pub of tsi.tokenPubs) {
+ const token = await tx.tokens.get(pub);
+ if (!token) {
+ throw Error(`token allocated for payment doesn't exist anymore`);
+ }
+
+ if (token.transactionId) {
+ if (token.transactionId !== tsi.transactionId) {
+ throw Error(`token already being spent in a different transaction`);
+ } else {
+ return;
+ }
+ }
+
+ token.transactionId = tsi.transactionId;
+ }
+}
+
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -62,12 +62,17 @@ import {
hashCoinEvInner,
hashCoinPub,
hashDenomPub,
+ hashPayWalletData,
+ hashTokenEv,
+ hashTokenIssuePub,
hashTruncate32,
kdf,
kdfKw,
keyExchangeEcdhEddsa,
Logger,
MakeSyncSignatureRequest,
+ MerchantContractOutputType,
+ PayWalletData,
PlanchetCreationRequest,
PlanchetUnblindInfo,
PurseDeposit,
@@ -78,10 +83,18 @@ import {
rsaUnblind,
rsaVerify,
setupTipPlanchet,
+ SignTokenUseRequest,
+ Slate,
+ SlateCreationRequest,
+ SlateUnblindInfo,
stringToBytes,
TalerProtocolTimestamp,
TalerSignaturePurpose,
timestampRoundedToBuffer,
+ TokenEnvelope,
+ TokenIssueBlindSig,
+ TokenIssuePublicKey,
+ TokenUseSig,
UnblindedSignature,
WireFee,
WithdrawalPlanchet,
@@ -140,6 +153,13 @@ export interface TalerCryptoInterface {
*/
createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet>;
+ /**
+ * Create a pre-token (a.k.a. slate) of a given token family.
+ */
+ createSlate(req: SlateCreationRequest): Promise<Slate>;
+
+ signTokenUse(req: SignTokenUseRequest): Promise<EddsaSigningResult>;
+
signTrackTransaction(
req: SignTrackTransactionRequest,
): Promise<EddsaSigningResult>;
@@ -154,6 +174,10 @@ export interface TalerCryptoInterface {
req: PaymentSignatureValidationRequest,
): Promise<ValidationResult>;
+ isValidTokenIssueSignature(
+ req: TokenSignatureValidationRequest,
+ ): Promise<ValidationResult>;
+
isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>;
isValidGlobalFees(
@@ -178,6 +202,10 @@ export interface TalerCryptoInterface {
req: UnblindDenominationSignatureRequest,
): Promise<UnblindedSignature>;
+ unblindTokenIssueSignature(
+ req: UnblindTokenIssueSignatureRequest,
+ ): Promise<UnblindedSignature>;
+
rsaUnblind(req: RsaUnblindRequest): Promise<RsaUnblindResponse>;
rsaVerify(req: RsaVerificationRequest): Promise<ValidationResult>;
@@ -292,6 +320,16 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<DerivedTipPlanchet> {
throw new Error("Function not implemented.");
},
+ createSlate: function (
+ req: SlateCreationRequest,
+ ): Promise<Slate> {
+ throw new Error("Function not implemented.");
+ },
+ signTokenUse: function (
+ req: SignTokenUseRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
signTrackTransaction: function (
req: SignTrackTransactionRequest,
): Promise<EddsaSigningResult> {
@@ -312,6 +350,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<ValidationResult> {
throw new Error("Function not implemented.");
},
+ isValidTokenIssueSignature: function (
+ req: TokenSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
isValidWireFee: function (
req: WireFeeValidationRequest,
): Promise<ValidationResult> {
@@ -350,6 +393,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<UnblindedSignature> {
throw new Error("Function not implemented.");
},
+ unblindTokenIssueSignature: function (
+ req: UnblindTokenIssueSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ throw new Error("Function not implemented.");
+ },
rsaUnblind: function (req: RsaUnblindRequest): Promise<RsaUnblindResponse> {
throw new Error("Function not implemented.");
},
@@ -606,6 +654,12 @@ export interface PaymentSignatureValidationRequest {
merchantPub: string;
}
+export interface TokenSignatureValidationRequest {
+ tokenUsePub: string;
+ tokenIssuePub: TokenIssuePublicKey;
+ sig: UnblindedSignature;
+}
+
export interface ContractTermsValidationRequest {
contractTermsHash: string;
sig: string;
@@ -648,6 +702,11 @@ export interface UnblindDenominationSignatureRequest {
evSig: BlindedDenominationSignature;
}
+export interface UnblindTokenIssueSignatureRequest {
+ slate: SlateUnblindInfo;
+ evSig: TokenIssueBlindSig;
+}
+
export interface FreshCoinEncoded {
coinPub: string;
coinPriv: string;
@@ -891,6 +950,110 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
return tipPlanchet;
},
+ async createSlate(
+ tci: TalerCryptoInterfaceR,
+ req: SlateCreationRequest,
+ ): Promise<Slate> {
+ if (req.tokenIssuePub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`unsupported cipher (${req.tokenIssuePub.cipher})`);
+ }
+
+ // token generation
+ const info = stringToBytes("taler-payment-token-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, req.outputIndex);
+ const secretSeedDec = decodeCrock(req.secretSeed);
+ const out = kdf(64, secretSeedDec, salt, info);
+ const tokenPriv = out.slice(0, 32);
+ const bks = encodeCrock(out.slice(32, 64));
+ const tokenPrivEnc = encodeCrock(tokenPriv);
+ const tokenPubRes = await tci.eddsaGetPublic(tci, {
+ priv: tokenPrivEnc,
+ });
+
+ // token hashing
+ const tokenPubHash = hash(decodeCrock(tokenPubRes.pub));
+
+ // token blinding
+ const blindResp = await tci.rsaBlind(tci, {
+ bks: bks,
+ hm: encodeCrock(tokenPubHash),
+ pub: req.tokenIssuePub.rsa_pub,
+ });
+
+ // token envelope
+ const tokenEv: TokenEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResp.blinded,
+ };
+
+ // token issue public key hash
+ const tokenIssuePubHash = hashTokenIssuePub(req.tokenIssuePub);
+ const evHash = hashTokenEv(
+ tokenEv,
+ encodeCrock(tokenIssuePubHash),
+ );
+
+ const choice = req.contractTerms.choices[req.outputIndex];
+ const tokenEvs: TokenEnvelope[] = [];
+ for (const output of choice.outputs) {
+ if (output.type !== MerchantContractOutputType.Token) {
+ continue;
+ }
+
+ const slug = output.token_family_slug;
+ const family = req.contractTerms.token_families[slug];
+ tokenEvs.push(...family.keys.map(key => {
+ let tokenEv: TokenEnvelope;
+ if (key.cipher === DenomKeyType.Rsa) {
+ tokenEv = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: key.rsa_pub,
+ };
+ } else {
+ throw Error(`unsupported cipher (${req.tokenIssuePub.cipher})`);
+ }
+ return tokenEv;
+ }));
+ }
+
+ // wallet data object with envelopes
+ const tokenWalletData: PayWalletData = {
+ choice_index: req.choiceIndex,
+ tokens_evs: tokenEvs,
+ };
+
+ return {
+ tokenPub: tokenPubRes.pub,
+ tokenPriv: tokenPrivEnc,
+ tokenIssuePub: req.tokenIssuePub,
+ tokenIssuePubHash: encodeCrock(tokenIssuePubHash),
+ tokenWalletData,
+ tokenEv,
+ tokenEvHash: encodeCrock(evHash),
+ blindingKey: bks,
+ };
+ },
+
+ async signTokenUse(
+ tci: TalerCryptoInterfaceR,
+ req: SignTokenUseRequest,
+ ): Promise<EddsaSigningResult> {
+ const tokenUseRequest = buildSigPS(
+ TalerSignaturePurpose.WALLET_TOKEN_USE,
+ )
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.walletDataHash))
+ .build();
+
+ return await tci.eddsaSign(tci, {
+ msg: encodeCrock(tokenUseRequest),
+ priv: req.tokenUsePriv,
+ });
+ },
+
async signTrackTransaction(
tci: TalerCryptoInterfaceR,
req: SignTrackTransactionRequest,
@@ -982,6 +1145,30 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
},
/**
+ * Check if a token issue signature is valid.
+ */
+ async isValidTokenIssueSignature(
+ tci: TalerCryptoInterfaceR,
+ req: TokenSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ if (req.sig.cipher !== req.tokenIssuePub.cipher) {
+ throw Error(`token issue signature mismatch`);
+ }
+
+ if (req.sig.cipher === DenomKeyType.Rsa) {
+ const {valid} = await tci.rsaVerify(tci, {
+ hm: req.tokenUsePub,
+ pk: req.tokenIssuePub.rsa_pub,
+ sig: req.sig.rsa_signature,
+ });
+
+ return {valid};
+ }
+
+ throw Error(`verification for ${req.sig.cipher} signature not implemented`);
+ },
+
+ /**
* Check if a wire fee is correctly signed.
*/
async isValidWireFee(
@@ -1137,6 +1324,31 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
}
},
+ async unblindTokenIssueSignature(
+ tci: TalerCryptoInterfaceR,
+ req: UnblindTokenIssueSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ if (req.evSig.cipher === DenomKeyType.Rsa) {
+ if (req.slate.tokenIssuePub.cipher !== DenomKeyType.Rsa) {
+ throw new Error(
+ "slate cipher does not match blind signature cipher",
+ );
+ }
+ const { sig } = await tci.rsaUnblind(tci, {
+ bk: req.slate.blindingKey,
+ blindedSig: req.evSig.blinded_rsa_signature,
+ pk: req.slate.tokenIssuePub.rsa_pub,
+ });
+
+ return {
+ cipher: DenomKeyType.Rsa,
+ rsa_signature: sig,
+ };
+ } else {
+ throw Error(`unblinding for cipher ${req.evSig.cipher} not implemented`);
+ }
+ },
+
/**
* Unblind a blindly signed value.
*/
@@ -1197,8 +1409,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
// All zeros.
hAgeCommitment = new Uint8Array(32);
}
- // FIXME: Actually allow passing user data here!
- const walletDataHash = new Uint8Array(64);
+ const walletDataHash = depositInfo.walletDataHash
+ ? decodeCrock(depositInfo.walletDataHash)
+ : new Uint8Array(64);
let d: Uint8Array;
if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -53,12 +53,17 @@ import {
ExchangeRefundRequest,
HashCodeString,
Logger,
+ MerchantContractTokenDetails,
+ MerchantContractTokenKind,
RefreshReason,
ScopeInfo,
TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
+ TokenEnvelope,
+ TokenIssuePublicKey,
+ TokenUseSig,
TransactionIdStr,
UnblindedSignature,
WireInfo,
@@ -943,6 +948,131 @@ export interface CoinRecord {
}
/**
+ * TokenFamilyRecord as stored in the "tokenFamilies"
+ * data store of the wallet database.
+ */
+export interface TokenRecord {
+ /**
+ * Source purchase of the token.
+ */
+ purchaseId: string;
+
+ /**
+ * Transaction where token is being used.
+ */
+ transactionId?: string;
+
+ /**
+ * Index of token in choices array.
+ */
+ choiceIndex?: number;
+
+ /**
+ * Index of token in outputs array.
+ */
+ outputIndex?: number;
+
+ /**
+ * URL of the merchant issuing the token.
+ */
+ merchantBaseUrl: string;
+
+ /**
+ * Kind of the token.
+ */
+ kind: MerchantContractTokenKind;
+
+ /**
+ * Identifier for the token family consisting of
+ * unreserved characters according to RFC 3986.
+ */
+ slug: string;
+
+ /**
+ * Human-readable name for the token family.
+ */
+ name: string;
+
+ /**
+ * Human-readable description for the token family.
+ */
+ description: string;
+
+ /**
+ * Optional map from IETF BCP 47 language tags to localized descriptions.
+ */
+ descriptionI18n: any | undefined;
+
+ /**
+ * Additional meta data, such as the trusted_domains
+ * or expected_domains. Depends on the kind.
+ */
+ extraData: MerchantContractTokenDetails;
+
+ /**
+ * Token issue public key used by merchant to verify tokens.
+ */
+ tokenIssuePub: TokenIssuePublicKey;
+
+ /**
+ * Hash of token issue public key.
+ */
+ tokenIssuePubHash: string;
+
+ /**
+ * Start time of the token family's validity period.
+ */
+ validAfter: DbProtocolTimestamp;
+
+ /**
+ * End time of the token family's validity period.
+ */
+ validBefore: DbProtocolTimestamp;
+
+ /**
+ * Unblinded token issue signature made by the merchant.
+ */
+ tokenIssueSig: UnblindedSignature;
+
+ /**
+ * Token use public key used to confirm usage of tokens.
+ */
+ tokenUsePub: string;
+
+ /**
+ * Token use private key used to verify usage of tokens.
+ */
+ tokenUsePriv: string;
+
+ /**
+ * Signature on token use request.
+ */
+ tokenUseSig?: TokenUseSig;
+
+ /**
+ * Envelope of the token.
+ */
+ tokenEv: TokenEnvelope;
+
+ /**
+ * Hash of the envelope.
+ */
+ tokenEvHash: string;
+
+ /**
+ * Blinding secret for token.
+ */
+ blindingKey: string;
+}
+
+/**
+ * Slate, a blank slice of rock cut for use as a writing surface,
+ * also the database representation of a token before being
+ * signed by the merchant, as stored in the `slates' data store.
+ */
+export type SlateRecord = Omit<TokenRecord, 'tokenIssueSig'>;
+
+/**
* History item for a coin.
*
* DB-specific format,
@@ -1249,6 +1379,10 @@ export interface DbCoinSelection {
coinContributions: AmountString[];
}
+export interface DbTokenSelection {
+ tokenPubs: string[];
+}
+
export interface PurchasePayInfo {
/**
* Undefined if payment is blocked by a pending refund.
@@ -1258,6 +1392,15 @@ export interface PurchasePayInfo {
* Undefined if payment is blocked by a pending refund.
*/
payCoinSelectionUid?: string;
+
+ payTokenSelection?: DbTokenSelection;
+
+ /**
+ * Whether token selection should be forced
+ * e.g. when merchant URL is not in `expected_domains'
+ */
+ payTokenForcedSel?: boolean;
+
totalPayCost: AmountString;
}
@@ -1314,6 +1457,17 @@ export interface PurchaseRecord {
noncePub: string;
/**
+ * Index of selected choice in the choices array.
+ */
+ choiceIndex?: number | undefined;
+
+ /**
+ * Secret seed used to derive slates.
+ * Stored since slates are created lazily.
+ */
+ secretSeed: string | undefined;
+
+ /**
* Downloaded and parsed proposal data.
*/
download: ProposalDownloadInfo | undefined;
@@ -2697,6 +2851,35 @@ export const WalletStoresV1 = {
),
},
),
+ tokens: describeStore(
+ "tokens",
+ describeContents<TokenRecord>({
+ keyPath: "tokenUsePub",
+ }),
+ {
+ bySlug: describeIndex("bySlug", "slug"),
+ byPurchaseIdAndChoiceIndex: describeIndex(
+ "byPurchaseIdAndChoiceIndex",
+ ["purchaseId", "choiceIndex"],
+ ),
+ }
+ ),
+ slates: describeStore(
+ "slates",
+ describeContents<SlateRecord>({
+ keyPath: "tokenUsePub",
+ }),
+ {
+ byPurchaseIdAndChoiceIndex: describeIndex(
+ "byPurchaseIdAndChoiceIndex",
+ ["purchaseId", "choiceIndex"],
+ ),
+ byPurchaseIdAndChoiceIndexAndOutputIndex: describeIndex(
+ "byPurchaseIdAndChoiceIndexAndOutputIndex",
+ ["purchaseId", "choiceIndex", "outputIndex"]
+ ),
+ },
+ ),
reserves: describeStore(
"reserves",
describeContents<ReserveRecord>({
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -43,6 +43,7 @@ import {
Logger,
MerchantContractTermsV0,
NotificationType,
+ MerchantContractVersion,
RefreshReason,
ScopeInfo,
SelectedProspectiveCoin,
@@ -1534,6 +1535,8 @@ async function processDepositGroupPendingDeposit(
depositGroup.contractTermsHash,
"",
);
+ if (contractData.version !== MerchantContractVersion.V0)
+ throw Error("assertion failed");
const ctx = new DepositTransactionContext(wex, depositGroupId);
@@ -1564,12 +1567,15 @@ async function processDepositGroupPendingDeposit(
const payCoinSel = await selectPayCoinsInTx(wex, tx, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.allowedExchanges,
+ exchanges: contractData.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
},
- restrictWireMethod: contractData.wireMethod,
+ restrictWireMethod: contractData.wire_method,
depositPaytoUri: dg.wire.payto_uri,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.max_fee),
prevPayCoins: [],
});
@@ -2183,6 +2189,10 @@ export async function createDepositGroup(
"",
);
+ if (contractData.version !== MerchantContractVersion.V0) {
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
let depositGroupId: string;
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -36,7 +36,7 @@ import {
CheckPayTemplateReponse,
CheckPayTemplateRequest,
codecForAbortResponse,
- codecForMerchantContractTermsV0,
+ codecForMerchantContractTerms,
codecForMerchantOrderStatusPaid,
codecForMerchantPayResponse,
codecForPostOrderResponse,
@@ -52,6 +52,7 @@ import {
encodeCrock,
ForcedCoinSel,
getRandomBytes,
+ hashPayWalletData,
HttpStatusCode,
j2s,
Logger,
@@ -59,7 +60,10 @@ import {
makePendingOperationFailedError,
makeTalerErrorDetail,
MerchantCoinRefundStatus,
- MerchantContractTermsV0,
+ MerchantContractOutputType,
+ MerchantContractTerms,
+ MerchantContractTermsV1,
+ MerchantContractVersion,
MerchantPayResponse,
MerchantUsingTemplateDetails,
NotificationType,
@@ -67,6 +71,7 @@ import {
parsePayTemplateUri,
parsePayUri,
parseTalerUri,
+ PayWalletData,
PreparePayResult,
PreparePayResultType,
PreparePayTemplateRequest,
@@ -77,6 +82,7 @@ import {
ScopeInfo,
SelectedProspectiveCoin,
SharePaymentResult,
+ SignedTokenEnvelope,
StartRefundQueryForUriResponse,
stringifyPayUri,
stringifyTalerUri,
@@ -88,6 +94,7 @@ import {
TalerPreciseTimestamp,
TalerProtocolViolationError,
TalerUriAction,
+ TokenUseSig,
Transaction,
TransactionAction,
TransactionIdStr,
@@ -117,6 +124,7 @@ import {
constructTaskIdentifier,
genericWaitForState,
genericWaitForStateVal,
+ spendTokens,
LookupFullTransactionOpts,
PendingTaskType,
spendCoins,
@@ -139,10 +147,12 @@ import {
RefundItemRecord,
RefundItemStatus,
RefundReason,
+ SlateRecord,
timestampPreciseFromDb,
timestampPreciseToDb,
timestampProtocolFromDb,
timestampProtocolToDb,
+ TokenRecord,
WalletDbAllStoresReadOnlyTransaction,
WalletDbReadOnlyTransaction,
WalletDbReadWriteTransaction,
@@ -168,6 +178,10 @@ import {
getDenomInfo,
WalletExecutionContext,
} from "./wallet.js";
+import {
+ selectPayTokensInTx,
+ SelectPayTokensResult,
+} from "./tokenSelection.js";
/**
* Logger.
@@ -239,7 +253,20 @@ export class PayMerchantTransactionContext implements TransactionContext {
purchaseRec.proposalId,
);
- const zero = Amounts.zeroOfAmount(contractData.amount);
+ let amountRaw: AmountString = "UNKNOWN:0";
+ if (contractData.version === MerchantContractVersion.V0) {
+ amountRaw = contractData.amount;
+ } else if (contractData.version === MerchantContractVersion.V1) {
+ const index = purchaseRec.choiceIndex;
+ if (index !== undefined) {
+ if (!(index in contractData.choices))
+ throw Error(`invalid choice index ${index}`);
+ amountRaw = contractData
+ .choices[index].amount;
+ }
+ }
+
+ let zero = Amounts.zeroOfAmount(amountRaw);
const info: OrderShortInfo = {
merchant: {
@@ -249,9 +276,9 @@ export class PayMerchantTransactionContext implements TransactionContext {
jurisdiction: contractData.merchant.jurisdiction,
website: contractData.merchant.website,
},
- orderId: contractData.orderId,
+ orderId: contractData.order_id,
summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
+ summary_i18n: contractData.summary_i18n,
contractTermsHash: contractData.contractTermsHash,
};
@@ -295,7 +322,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
txState,
scopes,
txActions: computePayMerchantTransactionActions(purchaseRec),
- amountRaw: Amounts.stringify(contractData.amount),
+ amountRaw,
amountEffective,
totalRefundRaw: Amounts.stringify(zero), // FIXME!
totalRefundEffective: Amounts.stringify(zero), // FIXME!
@@ -689,7 +716,7 @@ export class RefundTransactionContext implements TransactionContext {
paymentInfo = {
merchant: maybeContractData.merchant,
summary: maybeContractData.summary,
- summary_i18n: maybeContractData.summaryI18n,
+ summary_i18n: maybeContractData.summary_i18n,
};
}
const purchaseRecord = await tx.purchases.get(refundRecord.proposalId);
@@ -937,34 +964,15 @@ export async function expectProposalDownload(
}
export function extractContractData(
- parsedContractTerms: MerchantContractTermsV0,
+ parsedContractTerms: MerchantContractTerms,
contractTermsHash: string,
merchantSig: string,
): WalletContractData {
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
return {
- amount: Amounts.stringify(amount),
- contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
+ contractTermsHash,
merchantSig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- summaryI18n: parsedContractTerms.summary_i18n,
- minimumAge: parsedContractTerms.minimum_age,
+ ...parsedContractTerms,
};
}
@@ -1072,10 +1080,10 @@ async function processDownloadProposal(
proposalResp.contract_terms,
);
- let parsedContractTerms: MerchantContractTermsV0;
+ let parsedContractTerms: MerchantContractTerms;
try {
- parsedContractTerms = codecForMerchantContractTermsV0().decode(
+ parsedContractTerms = codecForMerchantContractTerms().decode(
proposalResp.contract_terms,
);
} catch (e) {
@@ -1156,10 +1164,20 @@ async function processDownloadProposal(
return;
}
const oldTxState = computePayMerchantTransactionState(p);
+
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ p.secretSeed = secretSeed;
+
+ // v1: currency is resolved after choice selection
+ let currency: string = "UNKNOWN";
+ if (contractData.version === MerchantContractVersion.V0) {
+ currency = Amounts.currencyOf(contractData.amount);
+ }
+
p.download = {
contractTermsHash,
contractTermsMerchantSig: contractData.merchantSig,
- currency: Amounts.currencyOf(contractData.amount),
+ currency,
fulfillmentUrl: contractData.fulfillmentUrl,
};
await tx.contractTerms.put({
@@ -1214,6 +1232,82 @@ async function processDownloadProposal(
return TaskRunResult.progress();
}
+async function generateSlate(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+ contractData: MerchantContractTermsV1,
+ choiceIndex: number,
+ outputIndex: number,
+): Promise<void> {
+ checkDbInvariant(
+ purchase.secretSeed !== undefined,
+ "can't process slates without secretSeed",
+ );
+
+ let slate = await wex.db.runReadOnlyTx({ storeNames: ["slates"]}, async (tx) => {
+ return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get([
+ purchase.proposalId,
+ choiceIndex,
+ outputIndex,
+ ]);
+ });
+
+ if (slate) {
+ return;
+ }
+
+ const choice = contractData.choices[choiceIndex];
+ const output = choice.outputs[outputIndex];
+ if (output.type !== MerchantContractOutputType.Token) {
+ throw new Error(`unsupported contract output type ${output.type}`);
+ }
+
+ const family = contractData.token_families[output.token_family_slug];
+ const key = family.keys[output.key_index];
+ const r = await wex.cryptoApi.createSlate({
+ secretSeed: purchase.secretSeed,
+ choiceIndex: choiceIndex,
+ outputIndex: outputIndex,
+ tokenIssuePub: key,
+ genTokenUseSig: true,
+ contractTerms: contractData,
+ contractTermsHash: ContractTermsUtil
+ .hashContractTerms(contractData),
+ });
+
+ const newSlate: SlateRecord = {
+ purchaseId: purchase.proposalId,
+ choiceIndex: choiceIndex,
+ outputIndex: outputIndex,
+ merchantBaseUrl: contractData.merchant_base_url,
+ kind: family.details.class,
+ slug: output.token_family_slug,
+ name: family.name,
+ description: family.description,
+ descriptionI18n: family.description_i18n,
+ validAfter: timestampProtocolToDb(key.signature_validity_start),
+ validBefore: timestampProtocolToDb(key.signature_validity_end),
+ extraData: family.details,
+ tokenIssuePub: r.tokenIssuePub,
+ tokenIssuePubHash: r.tokenIssuePubHash,
+ tokenUsePub: r.tokenPub,
+ tokenUsePriv: r.tokenPriv,
+ tokenEv: r.tokenEv,
+ tokenEvHash: r.tokenEvHash,
+ blindingKey: r.blindingKey,
+ };
+
+ await wex.db.runReadWriteTx({ storeNames: ["slates"] }, async (tx) => {
+ const s = await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get([
+ purchase.proposalId,
+ choiceIndex,
+ outputIndex,
+ ]);
+ if (s) return;
+ await tx.slates.put(newSlate);
+ });
+}
+
/**
* Create a new purchase transaction if necessary. If a purchase
* record for the provided arguments already exists,
@@ -1226,7 +1320,10 @@ async function createOrReusePurchase(
sessionId: string | undefined,
claimToken: string | undefined,
noncePriv: string | undefined,
-): Promise<string> {
+): Promise<{
+ proposalId: string,
+ transactionId: TransactionIdStr,
+}> {
const oldProposals = await wex.db.runReadOnlyTx(
{ storeNames: ["purchases"] },
async (tx) => {
@@ -1257,6 +1354,10 @@ async function createOrReusePurchase(
PurchaseStatus[oldProposal.purchaseStatus]
}) for order ${orderId} at ${merchantBaseUrl}`,
);
+ const ctx = new PayMerchantTransactionContext(
+ wex,
+ oldProposal.proposalId,
+ );
if (oldProposal.shared || oldProposal.createdFromShared) {
const download = await expectProposalDownload(wex, oldProposal);
const paid = await checkIfOrderIsAlreadyPaid(
@@ -1265,13 +1366,9 @@ async function createOrReusePurchase(
false,
);
logger.info(`old proposal paid: ${paid}`);
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
if (paid) {
- // if this transaction was shared and the order is paid then it
- // means that another wallet already paid the proposal
- const ctx = new PayMerchantTransactionContext(
- wex,
- oldProposal.proposalId,
- );
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["purchases", "transactionsMeta"] },
async (tx) => {
@@ -1296,7 +1393,10 @@ async function createOrReusePurchase(
notifyTransition(wex, transactionId, transitionInfo);
}
}
- return oldProposal.proposalId;
+ return {
+ proposalId: oldProposal.proposalId,
+ transactionId: ctx.transactionId,
+ };
}
let noncePair: EddsaKeyPairStrings;
@@ -1318,6 +1418,8 @@ async function createOrReusePurchase(
`created new proposal for ${orderId} at ${merchantBaseUrl} session ${sessionId}`,
);
+ const secretSeed = encodeCrock(getRandomBytes(32));
+
const proposalRecord: PurchaseRecord = {
download: undefined,
noncePriv: priv,
@@ -1333,6 +1435,7 @@ async function createOrReusePurchase(
autoRefundDeadline: undefined,
lastSessionId: undefined,
merchantPaySig: undefined,
+ secretSeed: secretSeed,
payInfo: undefined,
refundAmountAwaiting: undefined,
timestampAccept: undefined,
@@ -1364,7 +1467,10 @@ async function createOrReusePurchase(
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
- return proposalId;
+ return {
+ proposalId,
+ transactionId: ctx.transactionId,
+ };
}
async function storeFirstPaySuccess(
@@ -1414,7 +1520,7 @@ async function storeFirstPaySuccess(
dl.contractTermsHash,
dl.contractTermsMerchantSig,
);
- const protoAr = contractData.autoRefund;
+ const protoAr = contractData.auto_refund;
if (protoAr) {
const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present");
@@ -1552,6 +1658,10 @@ async function handleInsufficientFunds(
proposal,
);
+ if (contractData.version !== MerchantContractVersion.V0) {
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
const contrib = payCoinSelection.coinContributions[i];
@@ -1564,13 +1674,16 @@ async function handleInsufficientFunds(
const res = await selectPayCoinsInTx(wex, tx, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.allowedExchanges,
+ exchanges: contractData.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
},
- restrictWireMethod: contractData.wireMethod,
+ restrictWireMethod: contractData.wire_method,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.max_fee),
prevPayCoins,
- requiredMinimumAge: contractData.minimumAge,
+ requiredMinimumAge: contractData.minimum_age,
});
switch (res.type) {
@@ -1648,6 +1761,7 @@ async function checkPaymentByProposalId(
}
const d = await expectProposalDownload(wex, proposal);
const contractData = d.contractData;
+
const merchantSig = d.contractData.merchantSig;
if (!merchantSig) {
throw Error("BUG: proposal is in invalid state");
@@ -1655,8 +1769,6 @@ async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
- const currency = Amounts.currencyOf(contractData.amount);
-
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const transactionId = ctx.transactionId;
@@ -1682,25 +1794,36 @@ async function checkPaymentByProposalId(
purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
purchase.purchaseStatus === PurchaseStatus.DialogShared
) {
+ if (contractData.version === MerchantContractVersion.V1) {
+ return {
+ status: PreparePayResultType.ChoiceSelection,
+ transactionId,
+ contractTerms: d.contractTermsRaw,
+ contractTermsHash: contractData.contractTermsHash,
+ talerUri,
+ };
+ }
+
const instructedAmount = Amounts.parseOrThrow(contractData.amount);
// If not already paid, check if we could pay for it.
const res = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.allowedExchanges,
+ exchanges: contractData.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
},
contractTermsAmount: instructedAmount,
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.max_fee),
prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
- restrictWireMethod: contractData.wireMethod,
+ requiredMinimumAge: contractData.minimum_age,
+ restrictWireMethod: contractData.wire_method,
});
let coins: SelectedProspectiveCoin[] | undefined = undefined;
- const allowedExchangeUrls = contractData.allowedExchanges.map(
- (x) => x.exchangeBaseUrl,
- );
+ const allowedExchangeUrls = contractData.exchanges.map((x) => x.url);
switch (res.type) {
case "failure": {
@@ -1717,7 +1840,7 @@ async function checkPaymentByProposalId(
status: PreparePayResultType.InsufficientBalance,
contractTerms: d.contractTermsRaw,
transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
+ amountRaw: Amounts.stringify(contractData.amount),
scopes,
talerUri,
balanceDetails: res.insufficientBalanceDetails,
@@ -1733,6 +1856,7 @@ async function checkPaymentByProposalId(
assertUnreachable(res);
}
+ const currency = Amounts.currencyOf(contractData.amount);
const totalCost = await getTotalPaymentCost(wex, currency, coins);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1756,9 +1880,7 @@ async function checkPaymentByProposalId(
}
const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
- let exchangeUrls = contractData.allowedExchanges.map(
- (x) => x.exchangeBaseUrl,
- );
+ let exchangeUrls = contractData.exchanges.map((x) => x.url);
return await getScopeForAllExchanges(tx, exchangeUrls);
});
@@ -1794,12 +1916,31 @@ async function checkPaymentByProposalId(
await waitPaymentResult(wex, proposalId, sessionId);
const download = await expectProposalDownload(wex, purchase);
+ const contractData = download.contractData;
+
+ let amount: AmountString;
+ switch (contractData.version) {
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ break;
+ case MerchantContractVersion.V1:
+ const index = purchase.choiceIndex;
+ if (index === undefined)
+ throw Error("choice index not specified for contract v1");
+ if ((index in contractData.choices))
+ throw Error(`invalid choice index ${index}`)
+ amount = contractData.choices[index].amount;
+ break;
+ default:
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid: true,
- amountRaw: Amounts.stringify(download.contractData.amount),
+ amountRaw: Amounts.stringify(amount),
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
@@ -1809,12 +1950,30 @@ async function checkPaymentByProposalId(
};
} else if (!purchase.timestampFirstSuccessfulPay) {
const download = await expectProposalDownload(wex, purchase);
+
+ let amount: AmountString;
+ switch (download.contractData.version) {
+ case MerchantContractVersion.V0:
+ amount = download.contractData.amount;
+ break;
+ case MerchantContractVersion.V1:
+ const index = purchase.choiceIndex;
+ if (index === undefined)
+ throw Error(`choice index not specified for contract v1`);
+ if (!(index in download.contractData.choices))
+ throw Error(`invalid choice index ${index}`);
+ amount = download.contractData.choices[index].amount;
+ break;
+ default:
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther,
- amountRaw: Amounts.stringify(download.contractData.amount),
+ amountRaw: Amounts.stringify(amount),
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
@@ -1825,16 +1984,34 @@ async function checkPaymentByProposalId(
} else {
const paid = isPurchasePaid(purchase);
const download = await expectProposalDownload(wex, purchase);
+
+ let amount: AmountString;
+ switch (download.contractData.version) {
+ case MerchantContractVersion.V0:
+ amount = download.contractData.amount;
+ break;
+ case MerchantContractVersion.V1:
+ const index = purchase.choiceIndex;
+ if (index === undefined)
+ throw Error("choice index not specified for contract v1");
+ if (!(index in download.contractData.choices))
+ throw Error(`invalid choice index ${index}`);
+ amount = download.contractData.choices[index].amount;
+ break;
+ default:
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
paid,
- amountRaw: Amounts.stringify(download.contractData.amount),
+ amountRaw: Amounts.stringify(amount),
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
: undefined,
- ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ ...(paid ? { nextUrl: contractData.order_id } : {}),
scopes,
transactionId,
talerUri,
@@ -1893,7 +2070,7 @@ export async function preparePayForUri(
);
}
- const proposalId = await createOrReusePurchase(
+ const proposalRes = await createOrReusePurchase(
wex,
uriResult.merchantBaseUrl,
uriResult.orderId,
@@ -1902,9 +2079,9 @@ export async function preparePayForUri(
uriResult.noncePriv,
);
- await waitProposalDownloaded(wex, proposalId);
+ await waitProposalDownloaded(wex, proposalRes.proposalId);
- return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId);
+ return checkPaymentByProposalId(wex, proposalRes.proposalId, uriResult.sessionId);
}
/**
@@ -2083,6 +2260,7 @@ export async function generateDepositPermissions(
wex: WalletExecutionContext,
payCoinSel: DbCoinSelection,
contractData: WalletContractData,
+ walletData?: PayWalletData,
): Promise<CoinDepositPermission[]> {
const depositPermissions: CoinDepositPermission[] = [];
const coinWithDenom: Array<{
@@ -2114,7 +2292,7 @@ export async function generateDepositPermissions(
for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
const { coin, denom } = coinWithDenom[i];
let wireInfoHash: string;
- wireInfoHash = contractData.wireInfoHash;
+ wireInfoHash = contractData.h_wire;
const dp = await wex.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
@@ -2124,13 +2302,16 @@ export async function generateDepositPermissions(
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- merchantPub: contractData.merchantPub,
- refundDeadline: contractData.refundDeadline,
+ merchantPub: contractData.merchant_pub,
+ refundDeadline: contractData.refund_deadline,
spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
timestamp: contractData.timestamp,
wireInfoHash,
ageCommitmentProof: coin.ageCommitmentProof,
- requiredMinimumAge: contractData.minimumAge,
+ requiredMinimumAge: contractData.minimum_age,
+ walletDataHash: walletData
+ ? encodeCrock(hashPayWalletData(walletData))
+ : undefined,
});
depositPermissions.push(dp);
}
@@ -2221,6 +2402,8 @@ export async function confirmPay(
transactionId: string,
sessionIdOverride?: string,
forcedCoinSel?: ForcedCoinSel,
+ forcedTokenSel?: boolean,
+ choiceIndex?: number,
): Promise<ConfirmPayResult> {
const parsedTx = parseTransactionIdentifier(transactionId);
if (parsedTx?.tag !== TransactionType.Payment) {
@@ -2269,6 +2452,9 @@ export async function confirmPay(
);
if (existingPurchase && existingPurchase.payInfo) {
+ if (choiceIndex !== undefined && choiceIndex !== existingPurchase.choiceIndex)
+ throw Error(`cannot change choice index of existing purchase`);
+
logger.trace("confirmPay: submitting payment for existing purchase");
const ctx = new PayMerchantTransactionContext(
wex,
@@ -2280,9 +2466,28 @@ export async function confirmPay(
logger.trace("confirmPay: purchase record does not exist yet");
+ let amount: AmountString;
+ let maxFee: AmountString;
+ let currency: string;
const contractData = d.contractData;
-
- const currency = Amounts.currencyOf(contractData.amount);
+ switch (contractData.version) {
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ maxFee = contractData.max_fee;
+ currency = Amounts.currencyOf(amount);
+ break;
+ case MerchantContractVersion.V1:
+ if (choiceIndex === undefined)
+ throw Error("choice index not specified for contract v1");
+ if (!(choiceIndex in contractData.choices))
+ throw Error(`invalid choice index ${choiceIndex}`);
+ amount = contractData.choices[choiceIndex].amount;
+ maxFee = contractData.choices[choiceIndex].max_fee;
+ currency = Amounts.currencyOf(amount);
+ break;
+ default:
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
let sessionId: string | undefined;
if (sessionIdOverride) {
@@ -2303,16 +2508,39 @@ export async function confirmPay(
return;
}
+ let selectTokensResult: SelectPayTokensResult | undefined = undefined;
+
+ if (contractData.version === MerchantContractVersion.V1) {
+ selectTokensResult = await selectPayTokensInTx(tx, {
+ proposalId,
+ choiceIndex: choiceIndex!,
+ contractTerms: contractData,
+ forcedTokenSel: forcedTokenSel,
+ });
+
+ switch (selectTokensResult.type) {
+ case "failure": {
+ logger.warn("not confirming payment, insufficient tokens");
+ throw Error("insufficient tokens");
+ }
+ }
+
+ logger.trace("token selection result", selectTokensResult);
+ }
+
const selectCoinsResult = await selectPayCoinsInTx(wex, tx, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.allowedExchanges,
+ exchanges: contractData.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
},
- restrictWireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ restrictWireMethod: contractData.wire_method,
+ contractTermsAmount: Amounts.parseOrThrow(amount),
+ depositFeeLimit: Amounts.parseOrThrow(maxFee),
prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
+ requiredMinimumAge: contractData.minimum_age,
forcedSelection: forcedCoinSel,
});
@@ -2346,6 +2574,8 @@ export async function confirmPay(
coins,
);
+ p.choiceIndex = choiceIndex;
+
const oldTxState = computePayMerchantTransactionState(p);
switch (p.purchaseStatus) {
case PurchaseStatus.DialogShared:
@@ -2353,6 +2583,14 @@ export async function confirmPay(
p.payInfo = {
totalPayCost: Amounts.stringify(payCostInfo),
};
+ let tokenPubs: string[] | undefined = undefined;
+ if (selectTokensResult?.type === "success") {
+ const tokens = selectTokensResult.tokenSel.tokens.map(t => t.record);
+ tokenPubs = tokens.map(t => t.tokenUsePub);
+ p.payInfo.payTokenSelection = {
+ tokenPubs: selectTokensResult.tokenSel.tokens.map(t => t.record.tokenUsePub),
+ };
+ }
if (selectCoinsResult.type === "success") {
p.payInfo.payCoinSelection = {
coinContributions: selectCoinsResult.coinSel.coins.map(
@@ -2360,12 +2598,6 @@ export async function confirmPay(
),
coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
};
- p.exchanges = [
- ...new Set(
- selectCoinsResult.coinSel.coins.map((x) => x.exchangeBaseUrl),
- ),
- ];
- p.exchanges.sort();
p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
}
p.lastSessionId = sessionId;
@@ -2373,13 +2605,18 @@ export async function confirmPay(
p.purchaseStatus = PurchaseStatus.PendingPaying;
await tx.purchases.put(p);
await ctx.updateTransactionMeta(tx);
+ if (tokenPubs) {
+ await spendTokens(tx, {
+ tokenPubs, transactionId: ctx.transactionId,
+ });
+ }
if (p.payInfo.payCoinSelection) {
const sel = p.payInfo.payCoinSelection;
await spendCoins(wex, tx, {
transactionId: transactionId as TransactionIdStr,
coinPubs: sel.coinPubs,
contributions: sel.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
+ Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayMerchant,
});
@@ -2395,6 +2632,14 @@ export async function confirmPay(
},
);
+ // TODO: pre-generate slates based on choice priority!
+ if (choiceIndex !== undefined && contractData.version === MerchantContractVersion.V1) {
+ const choice = contractData.choices[choiceIndex];
+ for (let j = 0; j < choice.outputs.length; j++) {
+ await generateSlate(wex, proposal, contractData, choiceIndex!, j);
+ }
+ }
+
notifyTransition(wex, transactionId, transitionInfo);
// In case we're sharing the payment and we're long-polling
@@ -2555,19 +2800,43 @@ async function processPurchasePay(
}
const contractData = download.contractData;
- const currency = Amounts.currencyOf(download.contractData.amount);
+ const choiceIndex = purchase.choiceIndex;
+ let amount: AmountString;
+ let maxFee: AmountString;
+ let currency: string;
+ switch (contractData.version) {
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ maxFee = contractData.max_fee;
+ currency = Amounts.currencyOf(amount);
+ break;
+ case MerchantContractVersion.V1:
+ if (choiceIndex === undefined)
+ throw Error("choice index not specified for contract v1");
+ if (!(choiceIndex in contractData.choices))
+ throw Error(`invalid choice index ${choiceIndex}`);
+ amount = contractData.choices[choiceIndex].amount;
+ maxFee = contractData.choices[choiceIndex].max_fee;
+ currency = Amounts.currencyOf(amount);
+ break;
+ default:
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
if (!payInfo.payCoinSelection) {
const selectCoinsResult = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.allowedExchanges,
+ exchanges: contractData.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
},
- restrictWireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ restrictWireMethod: contractData.wire_method,
+ contractTermsAmount: Amounts.parseOrThrow(amount),
+ depositFeeLimit: Amounts.parseOrThrow(maxFee),
prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
+ requiredMinimumAge: contractData.minimum_age,
});
switch (selectCoinsResult.type) {
case "failure": {
@@ -2604,6 +2873,7 @@ async function processPurchasePay(
"purchases",
"refreshGroups",
"refreshSessions",
+ "tokens",
"transactionsMeta",
],
},
@@ -2658,23 +2928,60 @@ async function processPurchasePay(
if (!purchase.merchantPaySig) {
const payUrl = new URL(
- `orders/${download.contractData.orderId}/pay`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}/pay`,
+ download.contractData.merchant_base_url,
).href;
+ let slates: SlateRecord[] | undefined = undefined;
+ let wallet_data: PayWalletData | undefined = undefined;
+ if (contractData.version === MerchantContractVersion.V1 && purchase.choiceIndex !== undefined) {
+ const index = purchase.choiceIndex;
+ slates = [];
+ wallet_data = { choice_index: index, tokens_evs: [] };
+ await wex.db.runReadOnlyTx({
+ storeNames: ["slates"],
+ }, async (tx) => {
+ (await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll(
+ [purchase.proposalId, index],
+ )).forEach(s => {
+ slates?.push(s);
+ wallet_data?.tokens_evs.push(s.tokenEv);
+ });
+ });
+
+ if (slates.length !== contractData.choices[index].outputs.length) {
+ throw Error(`number of slates ${
+ slates.length
+ } doesn't match number of outputs ${
+ contractData.choices[index].outputs.length
+ }`);
+ }
+ }
+
let depositPermissions: CoinDepositPermission[];
// FIXME: Cache!
depositPermissions = await generateDepositPermissions(
wex,
payInfo.payCoinSelection,
download.contractData,
+ wallet_data,
);
- const reqBody = {
+ const reqBody: any = {
coins: depositPermissions,
+ wallet_data,
session_id: purchase.lastSessionId,
};
+ if (wallet_data && payInfo.payTokenSelection) {
+ reqBody.tokens = await generateTokenSigs(wex,
+ proposalId,
+ contractData.contractTermsHash,
+ encodeCrock(hashPayWalletData(wallet_data)),
+ payInfo.payTokenSelection.tokenPubs,
+ );
+ }
+
if (logger.shouldLogTrace()) {
logger.trace(`making pay request ... ${j2s(reqBody)}`);
}
@@ -2756,7 +3063,7 @@ async function processPurchasePay(
logger.trace("got success from pay URL", merchantResp);
- const merchantPub = download.contractData.merchantPub;
+ const merchantPub = download.contractData.merchant_pub;
const { valid } = await wex.cryptoApi.isValidPaymentSignature({
contractHash: download.contractData.contractTermsHash,
merchantPub,
@@ -2769,11 +3076,27 @@ async function processPurchasePay(
throw Error("merchant payment signature invalid");
}
+ if (slates && merchantResp.token_sigs) {
+ if (merchantResp.token_sigs.length !== slates.length) {
+ throw Error("merchant returned mismatching number of token signatures");
+ }
+
+ for (let i = 0; i < slates.length; i++) {
+ const slate = slates[i];
+ const sigEv = merchantResp.token_sigs[i];
+ await validateAndStoreToken(wex, slate, sigEv);
+ }
+ }
+
+ if (purchase.choiceIndex) {
+ await cleanupUsedTokens(wex, purchase.proposalId, purchase.choiceIndex);
+ }
+
await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
} else {
const payAgainUrl = new URL(
- `orders/${download.contractData.orderId}/paid`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}/paid`,
+ download.contractData.merchant_base_url,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
@@ -2805,6 +3128,117 @@ async function processPurchasePay(
return TaskRunResult.progress();
}
+export async function validateAndStoreToken(
+ wex: WalletExecutionContext,
+ slate: SlateRecord,
+ blindedEv: SignedTokenEnvelope,
+): Promise<void> {
+ const {tokenIssuePub, tokenUsePub, blindingKey} = slate;
+ const tokenIssueSig = await wex.cryptoApi.unblindTokenIssueSignature({
+ slate: {
+ tokenIssuePub,
+ blindingKey,
+ },
+ evSig: blindedEv.blind_sig,
+ });
+
+ const {valid} = await wex.cryptoApi.isValidTokenIssueSignature({
+ sig: tokenIssueSig,
+ tokenUsePub,
+ tokenIssuePub,
+ });
+
+ if (!valid) {
+ logger.error("token issue signature invalid");
+ // TODO: properly display error
+ throw Error("token issue signature invalid");
+ }
+
+ const token: TokenRecord = {
+ tokenIssueSig,
+ ...slate
+ };
+
+ // insert token and delete slate
+ await wex.db.runReadWriteTx({
+ storeNames: ["slates", "tokens"]
+ }, async (tx) => {
+ await tx.tokens.add(token);
+ await tx.slates.delete(slate.tokenUsePub);
+ });
+}
+
+export async function generateTokenSigs(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ contractTermsHash: string,
+ walletDataHash: string,
+ tokenPubs: string[],
+): Promise<TokenUseSig[]> {
+ const tokens: TokenRecord[] = [];
+ const sigs: TokenUseSig[] = [];
+ await wex.db.runReadOnlyTx({
+ storeNames: ["tokens", "purchases"],
+ }, async (tx) => {
+ for (const pub of tokenPubs) {
+ const token = await tx.tokens.get(pub);
+ checkDbInvariant(!!token, `token not found for ${pub}`);
+ tokens.push(token);
+ }
+ });
+
+ for (const token of tokens) {
+ if (token.tokenUseSig && token.purchaseId === proposalId) {
+ sigs.push(token.tokenUseSig);
+ continue;
+ }
+
+ const { sig } = await wex.cryptoApi.signTokenUse({
+ tokenUsePriv: token.tokenUsePriv,
+ walletDataHash,
+ contractTermsHash,
+ });
+
+ sigs.push({
+ token_sig: sig,
+ token_pub: token.tokenUsePub,
+ ub_sig: token.tokenIssueSig,
+ h_issue: token.tokenIssuePubHash,
+ });
+ }
+
+ await wex.db.runReadWriteTx({
+ storeNames: ["tokens"],
+ }, async (tx) => {
+ for (let i = 0; i < sigs.length; i++) {
+ const token = tokens[i];
+ const sig = sigs[i];
+ token.tokenUseSig = sig;
+ tx.tokens.put(token);
+ }
+ });
+
+ return sigs;
+}
+
+export async function cleanupUsedTokens(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ choiceIndex: number,
+): Promise<void> {
+ await wex.db.runReadWriteTx({
+ storeNames: ["tokens"]
+ }, async (tx) => {
+ const tokenPubs = await tx.tokens.indexes.byPurchaseIdAndChoiceIndex.getAllKeys(
+ [proposalId, choiceIndex],
+ );
+
+ for (const pub of tokenPubs) {
+ tx.tokens.delete(pub);
+ }
+ });
+}
+
export async function refuseProposal(
wex: WalletExecutionContext,
proposalId: string,
@@ -3224,8 +3658,8 @@ async function checkIfOrderIsAlreadyPaid(
doLongPolling: boolean,
) {
const requestUrl = new URL(
- `orders/${contract.orderId}`,
- contract.merchantBaseUrl,
+ `orders/${contract.order_id}`,
+ contract.merchant_base_url,
);
requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
@@ -3317,6 +3751,10 @@ async function processPurchaseAutoRefund(
logger.trace(`processing auto-refund for proposal ${proposalId}`);
const download = await expectProposalDownload(wex, purchase);
+ const contractData = download.contractData;
+ if (contractData.version !== MerchantContractVersion.V0) {
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
const noAutoRefundOrExpired =
!purchase.autoRefundDeadline ||
@@ -3332,7 +3770,7 @@ async function processPurchaseAutoRefund(
const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
purchase.proposalId,
);
- const am = Amounts.parseOrThrow(download.contractData.amount);
+ const am = Amounts.parseOrThrow(contractData.amount);
return refunds.reduce((prev, cur) => {
if (
cur.status === RefundGroupStatus.Done ||
@@ -3346,7 +3784,7 @@ async function processPurchaseAutoRefund(
);
const fullyRefunded =
- Amounts.cmp(download.contractData.amount, totalKnownRefund) <= 0;
+ Amounts.cmp(contractData.amount, totalKnownRefund) <= 0;
// We stop with the auto-refund state when the auto-refund period
// is over or the product is already fully refunded.
@@ -3381,8 +3819,8 @@ async function processPurchaseAutoRefund(
}
const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}`,
+ download.contractData.merchant_base_url,
);
requestUrl.searchParams.set(
"h_contract",
@@ -3449,8 +3887,8 @@ async function processPurchaseAbortingRefund(
logger.trace(`processing aborting-refund for proposal ${proposalId}`);
const requestUrl = new URL(
- `orders/${download.contractData.orderId}/abort`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}/abort`,
+ download.contractData.merchant_base_url,
);
const abortingCoins: AbortingCoin[] = [];
@@ -3552,8 +3990,8 @@ async function processPurchaseQueryRefund(
const download = await expectProposalDownload(wex, purchase);
const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}`,
+ download.contractData.merchant_base_url,
);
requestUrl.searchParams.set(
"h_contract",
@@ -3631,8 +4069,8 @@ async function processPurchaseAcceptRefund(
const download = await expectProposalDownload(wex, purchase);
const requestUrl = new URL(
- `orders/${download.contractData.orderId}/refund`,
- download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.order_id}/refund`,
+ download.contractData.merchant_base_url,
);
logger.trace(`making refund request to ${requestUrl.href}`);
@@ -3788,7 +4226,12 @@ async function storeRefunds(
const now = TalerPreciseTimestamp.now();
const download = await expectProposalDownload(wex, purchase);
- const currency = Amounts.currencyOf(download.contractData.amount);
+ const contractData = download.contractData;
+ if (contractData.version !== MerchantContractVersion.V0) {
+ throw Error(`unsupported contract version ${contractData.version}`);
+ }
+
+ const currency = Amounts.currencyOf(contractData.amount);
const transitions: {
transactionId: string;
@@ -3985,7 +4428,7 @@ async function storeRefunds(
await createRefreshGroup(
wex,
tx,
- Amounts.currencyOf(download.contractData.amount),
+ Amounts.currencyOf(contractData.amount),
refreshCoins,
RefreshReason.Refund,
// Since refunds are really just pseudo-transactions,
diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts
@@ -0,0 +1,142 @@
+import {
+ AbsoluteTime,
+ j2s,
+ Logger,
+ MerchantContractInputType,
+ MerchantContractTermsV1,
+ MerchantContractTokenKind,
+} from "@gnu-taler/taler-util";
+import { timestampProtocolFromDb, TokenRecord, WalletDbReadOnlyTransaction } from "./db.js";
+import { WalletExecutionContext } from "./index.js";
+
+const logger = new Logger("tokenSelection.ts");
+
+export interface SelectPayTokensRequest {
+ proposalId: string;
+ choiceIndex: number;
+ contractTerms: MerchantContractTermsV1;
+ forcedTokenSel?: boolean;
+}
+
+export interface SelectedTokens {
+ tokens: {
+ record: TokenRecord,
+ verification: TokenMerchantVerificationResult,
+ }[],
+}
+
+export type SelectPayTokensResult =
+ | { type: "failure" }
+ | { type: "success", tokenSel: SelectedTokens }
+
+export enum TokenMerchantVerificationResult {
+ /**
+ * Merchant is trusted/expected.
+ *
+ * Can be used automatically.
+ */
+ Automatic = "automatic",
+
+ /**
+ * Token used against untrusted merchant.
+ *
+ * User should not be allowed to use it.
+ */
+ Untrusted = "untrusted-domain",
+
+ /**
+ * Token used against unexpected merchant.
+ *
+ * User should be warned before using.
+ */
+ Unexpected = "unexpected-domain",
+}
+
+/**
+ * Verify that merchant URL matches `trusted_domains' or
+ * `expected_domains' in the token family.
+ */
+export function verifyTokenMerchant(
+ merchantBaseUrl: string,
+ token: TokenRecord,
+): TokenMerchantVerificationResult {
+ // TODO: implement properly
+ const data = token.extraData;
+ switch (data.class) {
+ case MerchantContractTokenKind.Discount:
+ return TokenMerchantVerificationResult.Automatic;
+ case MerchantContractTokenKind.Subscription:
+ return TokenMerchantVerificationResult.Automatic;
+ }
+}
+
+export async function selectPayTokensInTx(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "tokens",
+ "purchases",
+ ]
+ >,
+ req: SelectPayTokensRequest,
+): Promise<SelectPayTokensResult> {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selecting tokens for ${j2s(req)}`);
+ }
+
+ const proposal = await tx.purchases.get(req.proposalId);
+ if (!proposal) {
+ throw Error(`proposal ${req.proposalId} could not be found`);
+ }
+
+ const selection: SelectedTokens = {tokens: []};
+ const inputs = req.contractTerms.choices[req.choiceIndex].inputs;
+ for (const input of inputs) {
+ if (input.type == MerchantContractInputType.Token) {
+ // token with earliest expiration date that is still valid
+ const tokens = await tx.tokens.indexes.bySlug.getAll(input.token_family_slug);
+ const usable = tokens.filter(tok =>
+ !tok.transactionId && AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)),
+ AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)),
+ )).filter(tok => {
+ const res = verifyTokenMerchant(proposal.merchantBaseUrl, tok);
+ return res === TokenMerchantVerificationResult.Automatic || (
+ req.forcedTokenSel && res === TokenMerchantVerificationResult.Unexpected);
+ }).sort((a, b) => a.validBefore - b.validBefore).at(0);
+ if (!usable) {
+ return { type: "failure" };
+ }
+
+ selection.tokens.push({
+ record: usable,
+ verification: verifyTokenMerchant(
+ proposal.merchantBaseUrl,
+ usable,
+ ),
+ });
+ }
+ }
+
+ return {
+ type: "success",
+ tokenSel: selection,
+ };
+}
+
+export async function selectPayTokens(
+ wex: WalletExecutionContext,
+ req: SelectPayTokensRequest,
+): Promise<SelectPayTokensResult> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "tokens",
+ "purchases",
+ ]
+ },
+ async (tx) => {
+ return selectPayTokensInTx(tx, req);
+ }
+ );
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -80,6 +80,8 @@ import {
GetBankAccountByIdResponse,
GetBankingChoicesForPaytoRequest,
GetBankingChoicesForPaytoResponse,
+ GetChoicesForPaymentRequest,
+ GetChoicesForPaymentResult,
GetContractTermsDetailsRequest,
GetCurrencySpecificationRequest,
GetCurrencySpecificationResponse,
@@ -579,6 +581,24 @@ export type PreparePayForUriOp = {
response: PreparePayResult;
};
+/**
+ * Get a list of contract v1 choices for a given payment tx
+ * in dialog(confirm) state, as well as additional information
+ * on whether they can be used to pay the order or not, depending
+ * on the funds available on the wallet, or whether a specific
+ * choice should be paid automatically without user confirmation,
+ * based on user configuration or the type of payment requested.
+ *
+ * This request will fail if contract choices are not yet
+ * available, in which case the `choicesAvailable' field of the tx
+ * will be undefined or set to false.
+ */
+export type GetChoicesForPaymentOp = {
+ // op: WalletApiOperation.GetChoicesForPayment;
+ request: GetChoicesForPaymentRequest;
+ response: GetChoicesForPaymentResult;
+}
+
export type SharePaymentOp = {
op: WalletApiOperation.SharePayment;
request: SharePaymentRequest;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -1183,7 +1183,13 @@ async function handleConfirmPay(
wex: WalletExecutionContext,
req: ConfirmPayRequest,
): Promise<ConfirmPayResult> {
- return await confirmPay(wex, req.transactionId, req.sessionId);
+ return await confirmPay(wex,
+ req.transactionId,
+ req.sessionId,
+ undefined,
+ req.forcedTokenSel,
+ req.choiceIndex,
+ );
}
async function handleAbortTransaction(