taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 40c7fd37b6081a418d97632b7d23fe429b3500c4
parent a02f30c618222c5c1ecaab34e7a2b03de280ab18
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed,  5 Mar 2025 18:50:08 +0100

WIP: tokens implementation

Diffstat:
Apackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 3+++
Mpackages/taler-util/src/contract-terms.ts | 3+++
Mpackages/taler-util/src/taler-crypto.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 434+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 4++--
Mpackages/taler-util/src/types-taler-wallet.ts | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-util/src/types.test.ts | 8++++----
Mpackages/taler-wallet-cli/src/index.ts | 3+++
Mpackages/taler-wallet-core/src/common.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 217++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/db.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 16+++++++++++++---
Mpackages/taler-wallet-core/src/pay-merchant.ts | 663++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Apackages/taler-wallet-core/src/tokenSelection.ts | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 20++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 8+++++++-
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(