peer-to-peer pull payments MVP

p2p pull wip
This commit is contained in:
Florian Dold 2022-08-23 11:29:45 +02:00
parent 4ca38113ab
commit f3ff5a7225
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
14 changed files with 763 additions and 78 deletions

View File

@ -1214,6 +1214,9 @@ type ContractPrivateKey = FlavorP<Uint8Array, "ContractPrivateKey", 32> &
type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> & type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> &
MaterialEddsaPriv; MaterialEddsaPriv;
const mergeSalt = "p2p-merge-contract";
const depositSalt = "p2p-deposit-contract";
export function encryptContractForMerge( export function encryptContractForMerge(
pursePub: PursePublicKey, pursePub: PursePublicKey,
contractPriv: ContractPrivateKey, contractPriv: ContractPrivateKey,
@ -1230,12 +1233,24 @@ export function encryptContractForMerge(
contractTermsCompressed, contractTermsCompressed,
]); ]);
const key = keyExchangeEcdheEddsa(contractPriv, pursePub); const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
return encryptWithDerivedKey( return encryptWithDerivedKey(getRandomBytesF(24), key, data, mergeSalt);
getRandomBytesF(24), }
key,
data, export function encryptContractForDeposit(
"p2p-merge-contract", pursePub: PursePublicKey,
); contractPriv: ContractPrivateKey,
contractTerms: any,
): Promise<OpaqueData> {
const contractTermsCanon = canonicalJson(contractTerms) + "\0";
const contractTermsBytes = stringToBytes(contractTermsCanon);
const contractTermsCompressed = fflate.zlibSync(contractTermsBytes);
const data = typedArrayConcat([
bufferForUint32(ContractFormatTag.PaymentRequest),
bufferForUint32(contractTermsBytes.length),
contractTermsCompressed,
]);
const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
return encryptWithDerivedKey(getRandomBytesF(24), key, data, depositSalt);
} }
export interface DecryptForMergeResult { export interface DecryptForMergeResult {
@ -1243,13 +1258,17 @@ export interface DecryptForMergeResult {
mergePriv: Uint8Array; mergePriv: Uint8Array;
} }
export interface DecryptForDepositResult {
contractTerms: any;
}
export async function decryptContractForMerge( export async function decryptContractForMerge(
enc: OpaqueData, enc: OpaqueData,
pursePub: PursePublicKey, pursePub: PursePublicKey,
contractPriv: ContractPrivateKey, contractPriv: ContractPrivateKey,
): Promise<DecryptForMergeResult> { ): Promise<DecryptForMergeResult> {
const key = keyExchangeEcdheEddsa(contractPriv, pursePub); const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
const dec = await decryptWithDerivedKey(enc, key, "p2p-merge-contract"); const dec = await decryptWithDerivedKey(enc, key, mergeSalt);
const mergePriv = dec.slice(8, 8 + 32); const mergePriv = dec.slice(8, 8 + 32);
const contractTermsCompressed = dec.slice(8 + 32); const contractTermsCompressed = dec.slice(8 + 32);
const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed); const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed);
@ -1263,6 +1282,20 @@ export async function decryptContractForMerge(
}; };
} }
export function encryptContractForDeposit() { export async function decryptContractForDeposit(
throw Error("not implemented"); enc: OpaqueData,
pursePub: PursePublicKey,
contractPriv: ContractPrivateKey,
): Promise<DecryptForDepositResult> {
const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
const dec = await decryptWithDerivedKey(enc, key, depositSalt);
const contractTermsCompressed = dec.slice(8);
const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed);
// Slice of the '\0' at the end and decode to a string
const contractTermsString = bytesToString(
contractTermsBuf.slice(0, contractTermsBuf.length - 1),
);
return {
contractTerms: JSON.parse(contractTermsString),
};
} }

View File

@ -1874,3 +1874,74 @@ export interface PeerContractTerms {
summary: string; summary: string;
purse_expiration: TalerProtocolTimestamp; purse_expiration: TalerProtocolTimestamp;
} }
export interface EncryptedContract {
// Encrypted contract.
econtract: string;
// Signature over the (encrypted) contract.
econtract_sig: string;
// Ephemeral public key for the DH operation to decrypt the encrypted contract.
contract_pub: string;
}
/**
* Payload for /reserves/{reserve_pub}/purse
* endpoint of the exchange.
*/
export interface ExchangeReservePurseRequest {
/**
* Minimum amount that must be credited to the reserve, that is
* the total value of the purse minus the deposit fees.
* If the deposit fees are lower, the contribution to the
* reserve can be higher!
*/
purse_value: AmountString;
// Minimum age required for all coins deposited into the purse.
min_age: number;
// Purse fee the reserve owner is willing to pay
// for the purse creation. Optional, if not present
// the purse is to be created from the purse quota
// of the reserve.
purse_fee: AmountString;
// Optional encrypted contract, in case the buyer is
// proposing the contract and thus establishing the
// purse with the payment.
econtract?: EncryptedContract;
// EdDSA public key used to approve merges of this purse.
merge_pub: EddsaPublicKeyString;
// EdDSA signature of the purse private key affirming the merge
// over a TALER_PurseMergeSignaturePS.
// Must be of purpose TALER_SIGNATURE_PURSE_MERGE.
merge_sig: EddsaSignatureString;
// EdDSA signature of the account/reserve affirming the merge.
// Must be of purpose TALER_SIGNATURE_WALLET_ACCOUNT_MERGE
reserve_sig: EddsaSignatureString;
// Purse public key.
purse_pub: EddsaPublicKeyString;
// EdDSA signature of the purse over
// TALER_PurseRequestSignaturePS of
// purpose TALER_SIGNATURE_PURSE_REQUEST
// confirming that the
// above details hold for this purse.
purse_sig: EddsaSignatureString;
// SHA-512 hash of the contact of the purse.
h_contract_terms: HashCodeString;
// Client-side timestamp of when the merge request was made.
merge_timestamp: TalerProtocolTimestamp;
// Indicative time by which the purse should expire
// if it has not been paid.
purse_expiration: TalerProtocolTimestamp;
}

View File

@ -45,6 +45,11 @@ export interface PayPushUriResult {
contractPriv: string; contractPriv: string;
} }
export interface PayPullUriResult {
exchangeBaseUrl: string;
contractPriv: string;
}
/** /**
* Parse a taler[+http]://withdraw URI. * Parse a taler[+http]://withdraw URI.
* Return undefined if not passed a valid URI. * Return undefined if not passed a valid URI.
@ -84,10 +89,14 @@ export enum TalerUriType {
TalerTip = "taler-tip", TalerTip = "taler-tip",
TalerRefund = "taler-refund", TalerRefund = "taler-refund",
TalerNotifyReserve = "taler-notify-reserve", TalerNotifyReserve = "taler-notify-reserve",
TalerPayPush = "pay-push", TalerPayPush = "taler-pay-push",
TalerPayPull = "taler-pay-pull",
Unknown = "unknown", Unknown = "unknown",
} }
const talerActionPayPull = "pay-pull";
const talerActionPayPush = "pay-push";
/** /**
* Classify a taler:// URI. * Classify a taler:// URI.
*/ */
@ -117,12 +126,18 @@ export function classifyTalerUri(s: string): TalerUriType {
if (sl.startsWith("taler+http://withdraw/")) { if (sl.startsWith("taler+http://withdraw/")) {
return TalerUriType.TalerWithdraw; return TalerUriType.TalerWithdraw;
} }
if (sl.startsWith("taler://pay-push/")) { if (sl.startsWith(`taler://${talerActionPayPush}/`)) {
return TalerUriType.TalerPayPush; return TalerUriType.TalerPayPush;
} }
if (sl.startsWith("taler+http://pay-push/")) { if (sl.startsWith(`taler+http://${talerActionPayPush}/`)) {
return TalerUriType.TalerPayPush; return TalerUriType.TalerPayPush;
} }
if (sl.startsWith(`taler://${talerActionPayPull}/`)) {
return TalerUriType.TalerPayPull;
}
if (sl.startsWith(`taler+http://${talerActionPayPull}/`)) {
return TalerUriType.TalerPayPull;
}
if (sl.startsWith("taler://notify-reserve/")) { if (sl.startsWith("taler://notify-reserve/")) {
return TalerUriType.TalerNotifyReserve; return TalerUriType.TalerNotifyReserve;
} }
@ -189,7 +204,29 @@ export function parsePayUri(s: string): PayUriResult | undefined {
} }
export function parsePayPushUri(s: string): PayPushUriResult | undefined { export function parsePayPushUri(s: string): PayPushUriResult | undefined {
const pi = parseProtoInfo(s, "pay-push"); const pi = parseProtoInfo(s, talerActionPayPush);
if (!pi) {
return undefined;
}
const c = pi?.rest.split("?");
const parts = c[0].split("/");
if (parts.length < 2) {
return undefined;
}
const host = parts[0].toLowerCase();
const contractPriv = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
const p = [host, ...pathSegments].join("/");
const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
return {
exchangeBaseUrl,
contractPriv,
};
}
export function parsePayPullUri(s: string): PayPullUriResult | undefined {
const pi = parseProtoInfo(s, talerActionPayPull);
if (!pi) { if (!pi) {
return undefined; return undefined;
} }
@ -283,3 +320,24 @@ export function constructPayPushUri(args: {
} }
return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`; return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`;
} }
export function constructPayPullUri(args: {
exchangeBaseUrl: string;
contractPriv: string;
}): string {
const url = new URL(args.exchangeBaseUrl);
let proto: string;
if (url.protocol === "https:") {
proto = "taler";
} else if (url.protocol === "http:") {
proto = "taler+http";
} else {
throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
}
if (!url.pathname.endsWith("/")) {
throw Error(
`exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`,
);
}
return `${proto}://pay-pull/${url.host}${url.pathname}${args.contractPriv}`;
}

View File

@ -627,7 +627,7 @@ export interface ExchangeAccount {
master_sig: string; master_sig: string;
} }
export type WireFeeMap = { [wireMethod: string]: WireFee[] } export type WireFeeMap = { [wireMethod: string]: WireFee[] };
export interface WireInfo { export interface WireInfo {
feesForType: WireFeeMap; feesForType: WireFeeMap;
accounts: ExchangeAccount[]; accounts: ExchangeAccount[];
@ -639,7 +639,6 @@ const codecForExchangeAccount = (): Codec<ExchangeAccount> =>
.property("master_sig", codecForString()) .property("master_sig", codecForString())
.build("codecForExchangeAccount"); .build("codecForExchangeAccount");
const codecForWireFee = (): Codec<WireFee> => const codecForWireFee = (): Codec<WireFee> =>
buildCodecForObject<WireFee>() buildCodecForObject<WireFee>()
.property("sig", codecForString()) .property("sig", codecForString())
@ -658,19 +657,18 @@ const codecForWireInfo = (): Codec<WireInfo> =>
const codecForDenominationInfo = (): Codec<DenominationInfo> => const codecForDenominationInfo = (): Codec<DenominationInfo> =>
buildCodecForObject<DenominationInfo>() buildCodecForObject<DenominationInfo>()
.property("denomPubHash", (codecForString())) .property("denomPubHash", codecForString())
.property("value", (codecForAmountJson())) .property("value", codecForAmountJson())
.property("feeWithdraw", (codecForAmountJson())) .property("feeWithdraw", codecForAmountJson())
.property("feeDeposit", (codecForAmountJson())) .property("feeDeposit", codecForAmountJson())
.property("feeRefresh", (codecForAmountJson())) .property("feeRefresh", codecForAmountJson())
.property("feeRefund", (codecForAmountJson())) .property("feeRefund", codecForAmountJson())
.property("stampStart", (codecForTimestamp)) .property("stampStart", codecForTimestamp)
.property("stampExpireWithdraw", (codecForTimestamp)) .property("stampExpireWithdraw", codecForTimestamp)
.property("stampExpireLegal", (codecForTimestamp)) .property("stampExpireLegal", codecForTimestamp)
.property("stampExpireDeposit", (codecForTimestamp)) .property("stampExpireDeposit", codecForTimestamp)
.build("codecForDenominationInfo"); .build("codecForDenominationInfo");
export interface DenominationInfo { export interface DenominationInfo {
value: AmountJson; value: AmountJson;
denomPubHash: string; denomPubHash: string;
@ -713,7 +711,6 @@ export interface DenominationInfo {
* Data after which coins of this denomination can't be deposited anymore. * Data after which coins of this denomination can't be deposited anymore.
*/ */
stampExpireDeposit: TalerProtocolTimestamp; stampExpireDeposit: TalerProtocolTimestamp;
} }
export interface ExchangeListItem { export interface ExchangeListItem {
@ -726,7 +723,6 @@ export interface ExchangeListItem {
denominations: DenominationInfo[]; denominations: DenominationInfo[];
} }
const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
buildCodecForObject<AuditorDenomSig>() buildCodecForObject<AuditorDenomSig>()
.property("denom_pub_h", codecForString()) .property("denom_pub_h", codecForString())
@ -740,7 +736,6 @@ const codecForExchangeAuditor = (): Codec<ExchangeAuditor> =>
.property("denomination_keys", codecForList(codecForAuditorDenomSig())) .property("denomination_keys", codecForList(codecForAuditorDenomSig()))
.build("codecForExchangeAuditor"); .build("codecForExchangeAuditor");
const codecForExchangeTos = (): Codec<ExchangeTos> => const codecForExchangeTos = (): Codec<ExchangeTos> =>
buildCodecForObject<ExchangeTos>() buildCodecForObject<ExchangeTos>()
.property("acceptedVersion", codecOptional(codecForString())) .property("acceptedVersion", codecOptional(codecForString()))
@ -1452,18 +1447,34 @@ export interface CheckPeerPushPaymentRequest {
talerUri: string; talerUri: string;
} }
export interface CheckPeerPullPaymentRequest {
talerUri: string;
}
export interface CheckPeerPushPaymentResponse { export interface CheckPeerPushPaymentResponse {
contractTerms: any; contractTerms: any;
amount: AmountString; amount: AmountString;
peerPushPaymentIncomingId: string; peerPushPaymentIncomingId: string;
} }
export interface CheckPeerPullPaymentResponse {
contractTerms: any;
amount: AmountString;
peerPullPaymentIncomingId: string;
}
export const codecForCheckPeerPushPaymentRequest = export const codecForCheckPeerPushPaymentRequest =
(): Codec<CheckPeerPushPaymentRequest> => (): Codec<CheckPeerPushPaymentRequest> =>
buildCodecForObject<CheckPeerPushPaymentRequest>() buildCodecForObject<CheckPeerPushPaymentRequest>()
.property("talerUri", codecForString()) .property("talerUri", codecForString())
.build("CheckPeerPushPaymentRequest"); .build("CheckPeerPushPaymentRequest");
export const codecForCheckPeerPullPaymentRequest =
(): Codec<CheckPeerPullPaymentRequest> =>
buildCodecForObject<CheckPeerPullPaymentRequest>()
.property("talerUri", codecForString())
.build("CheckPeerPullPaymentRequest");
export interface AcceptPeerPushPaymentRequest { export interface AcceptPeerPushPaymentRequest {
/** /**
* Transparent identifier of the incoming peer push payment. * Transparent identifier of the incoming peer push payment.
@ -1476,3 +1487,41 @@ export const codecForAcceptPeerPushPaymentRequest =
buildCodecForObject<AcceptPeerPushPaymentRequest>() buildCodecForObject<AcceptPeerPushPaymentRequest>()
.property("peerPushPaymentIncomingId", codecForString()) .property("peerPushPaymentIncomingId", codecForString())
.build("AcceptPeerPushPaymentRequest"); .build("AcceptPeerPushPaymentRequest");
export interface AcceptPeerPullPaymentRequest {
/**
* Transparent identifier of the incoming peer pull payment.
*/
peerPullPaymentIncomingId: string;
}
export const codecForAcceptPeerPullPaymentRequest =
(): Codec<AcceptPeerPullPaymentRequest> =>
buildCodecForObject<AcceptPeerPullPaymentRequest>()
.property("peerPullPaymentIncomingId", codecForString())
.build("AcceptPeerPllPaymentRequest");
export interface InitiatePeerPullPaymentRequest {
/**
* FIXME: Make this optional?
*/
exchangeBaseUrl: string;
amount: AmountString;
partialContractTerms: any;
}
export const codecForInitiatePeerPullPaymentRequest =
(): Codec<InitiatePeerPullPaymentRequest> =>
buildCodecForObject<InitiatePeerPullPaymentRequest>()
.property("partialContractTerms", codecForAny())
.property("amount", codecForAmountString())
.property("exchangeBaseUrl", codecForAmountString())
.build("InitiatePeerPullPaymentRequest");
export interface InitiatePeerPullPaymentResponse {
/**
* Taler URI for the other party to make the payment
* that was requested.
*/
talerUri: string;
}

View File

@ -1284,7 +1284,7 @@ export class ExchangeService implements ExchangeServiceInterface {
// account fee // account fee
`${this.exchangeConfig.currency}:0.01`, `${this.exchangeConfig.currency}:0.01`,
// purse fee // purse fee
`${this.exchangeConfig.currency}:0.01`, `${this.exchangeConfig.currency}:0.00`,
// purse timeout // purse timeout
"1h", "1h",
// kyc timeout // kyc timeout

View File

@ -0,0 +1,70 @@
/*
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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironment,
withdrawViaBank,
} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal and payment.
*/
export async function runPeerToPeerPullTest(t: GlobalTestState) {
// Set up test environment
const { wallet, bank, exchange, merchant } =
await createSimpleTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
await wallet.runUntilDone();
const resp = await wallet.client.call(
WalletApiOperation.InitiatePeerPullPayment,
{
exchangeBaseUrl: exchange.baseUrl,
amount: "TESTKUDOS:5",
partialContractTerms: {
summary: "Hello World",
},
},
);
const checkResp = await wallet.client.call(
WalletApiOperation.CheckPeerPullPayment,
{
talerUri: resp.talerUri,
},
);
const acceptResp = await wallet.client.call(
WalletApiOperation.AcceptPeerPullPayment,
{
peerPullPaymentIncomingId: checkResp.peerPullPaymentIncomingId,
},
);
await wallet.runUntilDone();
}
runPeerToPeerPullTest.suites = ["wallet"];

View File

@ -27,7 +27,7 @@ import {
/** /**
* Run test for basic, bank-integrated withdrawal and payment. * Run test for basic, bank-integrated withdrawal and payment.
*/ */
export async function runPeerToPeerTest(t: GlobalTestState) { export async function runPeerToPeerPushTest(t: GlobalTestState) {
// Set up test environment // Set up test environment
const { wallet, bank, exchange, merchant } = const { wallet, bank, exchange, merchant } =
@ -72,4 +72,4 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
await wallet.runUntilDone(); await wallet.runUntilDone();
} }
runPeerToPeerTest.suites = ["wallet"]; runPeerToPeerPushTest.suites = ["wallet"];

View File

@ -73,7 +73,8 @@ import { runPaymentDemoTest } from "./test-payment-on-demo";
import { runPaymentTransientTest } from "./test-payment-transient"; import { runPaymentTransientTest } from "./test-payment-transient";
import { runPaymentZeroTest } from "./test-payment-zero.js"; import { runPaymentZeroTest } from "./test-payment-zero.js";
import { runPaywallFlowTest } from "./test-paywall-flow"; import { runPaywallFlowTest } from "./test-paywall-flow";
import { runPeerToPeerTest } from "./test-peer-to-peer.js"; import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js";
import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js";
import { runRefundTest } from "./test-refund"; import { runRefundTest } from "./test-refund";
import { runRefundAutoTest } from "./test-refund-auto"; import { runRefundAutoTest } from "./test-refund-auto";
import { runRefundGoneTest } from "./test-refund-gone"; import { runRefundGoneTest } from "./test-refund-gone";
@ -154,7 +155,8 @@ const allTests: TestMainFunction[] = [
runPaymentZeroTest, runPaymentZeroTest,
runPayPaidTest, runPayPaidTest,
runPaywallFlowTest, runPaywallFlowTest,
runPeerToPeerTest, runPeerToPeerPushTest,
runPeerToPeerPullTest,
runRefundAutoTest, runRefundAutoTest,
runRefundGoneTest, runRefundGoneTest,
runRefundIncrementalTest, runRefundIncrementalTest,

View File

@ -33,11 +33,11 @@ import {
BlindedDenominationSignature, BlindedDenominationSignature,
bufferForUint32, bufferForUint32,
buildSigPS, buildSigPS,
bytesToString,
CoinDepositPermission, CoinDepositPermission,
CoinEnvelope, CoinEnvelope,
createHashContext, createHashContext,
decodeCrock, decodeCrock,
decryptContractForDeposit,
decryptContractForMerge, decryptContractForMerge,
DenomKeyType, DenomKeyType,
DepositInfo, DepositInfo,
@ -47,6 +47,7 @@ import {
eddsaSign, eddsaSign,
eddsaVerify, eddsaVerify,
encodeCrock, encodeCrock,
encryptContractForDeposit,
encryptContractForMerge, encryptContractForMerge,
ExchangeProtocolVersion, ExchangeProtocolVersion,
getRandomBytes, getRandomBytes,
@ -85,17 +86,23 @@ import { DenominationRecord } from "../db.js";
import { import {
CreateRecoupRefreshReqRequest, CreateRecoupRefreshReqRequest,
CreateRecoupReqRequest, CreateRecoupReqRequest,
DecryptContractForDepositRequest,
DecryptContractForDepositResponse,
DecryptContractRequest, DecryptContractRequest,
DecryptContractResponse, DecryptContractResponse,
DerivedRefreshSession, DerivedRefreshSession,
DerivedTipPlanchet, DerivedTipPlanchet,
DeriveRefreshSessionRequest, DeriveRefreshSessionRequest,
DeriveTipRequest, DeriveTipRequest,
EncryptContractForDepositRequest,
EncryptContractForDepositResponse,
EncryptContractRequest, EncryptContractRequest,
EncryptContractResponse, EncryptContractResponse,
EncryptedContract, EncryptedContract,
SignPurseMergeRequest, SignPurseMergeRequest,
SignPurseMergeResponse, SignPurseMergeResponse,
SignReservePurseCreateRequest,
SignReservePurseCreateResponse,
SignTrackTransactionRequest, SignTrackTransactionRequest,
} from "./cryptoTypes.js"; } from "./cryptoTypes.js";
@ -205,7 +212,19 @@ export interface TalerCryptoInterface {
req: DecryptContractRequest, req: DecryptContractRequest,
): Promise<DecryptContractResponse>; ): Promise<DecryptContractResponse>;
encryptContractForDeposit(
req: EncryptContractForDepositRequest,
): Promise<EncryptContractForDepositResponse>;
decryptContractForDeposit(
req: DecryptContractForDepositRequest,
): Promise<DecryptContractForDepositResponse>;
signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>; signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
signReservePurseCreate(
req: SignReservePurseCreateRequest,
): Promise<SignReservePurseCreateResponse>;
} }
/** /**
@ -362,6 +381,21 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<SignPurseMergeResponse> { ): Promise<SignPurseMergeResponse> {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}, },
encryptContractForDeposit: function (
req: EncryptContractForDepositRequest,
): Promise<EncryptContractForDepositResponse> {
throw new Error("Function not implemented.");
},
decryptContractForDeposit: function (
req: DecryptContractForDepositRequest,
): Promise<DecryptContractForDepositResponse> {
throw new Error("Function not implemented.");
},
signReservePurseCreate: function (
req: SignReservePurseCreateRequest,
): Promise<SignReservePurseCreateResponse> {
throw new Error("Function not implemented.");
},
}; };
export type WithArg<X> = X extends (req: infer T) => infer R export type WithArg<X> = X extends (req: infer T) => infer R
@ -1047,7 +1081,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
if (depositInfo.requiredMinimumAge != null) { if (depositInfo.requiredMinimumAge != null) {
s.minimum_age_sig = minimumAgeSig; s.minimum_age_sig = minimumAgeSig;
s.age_commitment = depositInfo.ageCommitmentProof?.commitment.publicKeys; s.age_commitment =
depositInfo.ageCommitmentProof?.commitment.publicKeys;
} else if (depositInfo.ageCommitmentProof) { } else if (depositInfo.ageCommitmentProof) {
(s as any).h_age_commitment = hAgeCommitment; (s as any).h_age_commitment = hAgeCommitment;
} }
@ -1389,6 +1424,43 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
mergePriv: encodeCrock(res.mergePriv), mergePriv: encodeCrock(res.mergePriv),
}; };
}, },
async encryptContractForDeposit(
tci: TalerCryptoInterfaceR,
req: EncryptContractForDepositRequest,
): Promise<EncryptContractForDepositResponse> {
const contractKeyPair = await this.createEddsaKeypair(tci, {});
const enc = await encryptContractForDeposit(
decodeCrock(req.pursePub),
decodeCrock(contractKeyPair.priv),
req.contractTerms,
);
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
.put(hash(enc))
.put(decodeCrock(contractKeyPair.pub))
.build();
const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
return {
econtract: {
contract_pub: contractKeyPair.pub,
econtract: encodeCrock(enc),
econtract_sig: encodeCrock(sig),
},
contractPriv: contractKeyPair.priv,
};
},
async decryptContractForDeposit(
tci: TalerCryptoInterfaceR,
req: DecryptContractForDepositRequest,
): Promise<DecryptContractForDepositResponse> {
const res = await decryptContractForDeposit(
decodeCrock(req.ciphertext),
decodeCrock(req.pursePub),
decodeCrock(req.contractPriv),
);
return {
contractTerms: res.contractTerms,
};
},
async signPurseMerge( async signPurseMerge(
tci: TalerCryptoInterfaceR, tci: TalerCryptoInterfaceR,
req: SignPurseMergeRequest, req: SignPurseMergeRequest,
@ -1431,6 +1503,70 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
accountSig: reserveSigResp.sig, accountSig: reserveSigResp.sig,
}; };
}, },
async signReservePurseCreate(
tci: TalerCryptoInterfaceR,
req: SignReservePurseCreateRequest,
): Promise<SignReservePurseCreateResponse> {
const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
.put(timestampRoundedToBuffer(req.mergeTimestamp))
.put(decodeCrock(req.pursePub))
.put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
.build();
const mergeSigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(mergeSigBlob),
priv: req.mergePriv,
});
logger.info(`payto URI: ${req.reservePayto}`);
logger.info(
`signing WALLET_PURSE_MERGE over ${encodeCrock(mergeSigBlob)}`,
);
const reserveSigBlob = buildSigPS(
TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
)
.put(timestampRoundedToBuffer(req.purseExpiration))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
.put(decodeCrock(req.contractTermsHash))
.put(decodeCrock(req.pursePub))
.put(timestampRoundedToBuffer(req.mergeTimestamp))
// FIXME: put in min_age
.put(bufferForUint32(0))
.put(bufferForUint32(req.flags))
.build();
logger.info(
`signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
);
const reserveSigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(reserveSigBlob),
priv: req.reservePriv,
});
const mergePub = encodeCrock(eddsaGetPublic(decodeCrock(req.mergePriv)));
const purseSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
.put(timestampRoundedToBuffer(req.purseExpiration))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
.put(decodeCrock(req.contractTermsHash))
.put(decodeCrock(mergePub))
// FIXME: add age!
.put(bufferForUint32(0))
.build();
const purseSigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(purseSigBlob),
priv: req.pursePriv,
});
return {
mergeSig: mergeSigResp.sig,
accountSig: reserveSigResp.sig,
purseSig: purseSigResp.sig,
};
},
}; };
function amountToBuffer(amount: AmountJson): Uint8Array { function amountToBuffer(amount: AmountJson): Uint8Array {

View File

@ -187,6 +187,19 @@ export interface EncryptContractResponse {
contractPriv: string; contractPriv: string;
} }
export interface EncryptContractForDepositRequest {
contractTerms: any;
pursePub: string;
pursePriv: string;
}
export interface EncryptContractForDepositResponse {
econtract: EncryptedContract;
contractPriv: string;
}
export interface DecryptContractRequest { export interface DecryptContractRequest {
ciphertext: string; ciphertext: string;
pursePub: string; pursePub: string;
@ -198,6 +211,16 @@ export interface DecryptContractResponse {
mergePriv: string; mergePriv: string;
} }
export interface DecryptContractForDepositRequest {
ciphertext: string;
pursePub: string;
contractPriv: string;
}
export interface DecryptContractForDepositResponse {
contractTerms: any;
}
export interface SignPurseMergeRequest { export interface SignPurseMergeRequest {
mergeTimestamp: TalerProtocolTimestamp; mergeTimestamp: TalerProtocolTimestamp;
@ -227,6 +250,47 @@ export interface SignPurseMergeResponse {
* Signature made by the purse's merge private key. * Signature made by the purse's merge private key.
*/ */
mergeSig: string; mergeSig: string;
accountSig: string; accountSig: string;
} }
export interface SignReservePurseCreateRequest {
mergeTimestamp: TalerProtocolTimestamp;
pursePub: string;
pursePriv: string;
reservePayto: string;
reservePriv: string;
mergePriv: string;
purseExpiration: TalerProtocolTimestamp;
purseAmount: AmountString;
purseFee: AmountString;
contractTermsHash: string;
/**
* Flags.
*/
flags: WalletAccountMergeFlags;
}
/**
* Response with signatures needed for creation of a purse
* from a reserve for a PULL payment.
*/
export interface SignReservePurseCreateResponse {
/**
* Signature made by the purse's merge private key.
*/
mergeSig: string;
accountSig: string;
purseSig: string;
}

View File

@ -393,7 +393,6 @@ export interface ExchangeDetailsRecord {
wireInfo: WireInfo; wireInfo: WireInfo;
} }
export interface ExchangeDetailsPointer { export interface ExchangeDetailsPointer {
masterPublicKey: string; masterPublicKey: string;
@ -922,7 +921,6 @@ export interface RefreshSessionRecord {
norevealIndex?: number; norevealIndex?: number;
} }
export enum RefundState { export enum RefundState {
Failed = "failed", Failed = "failed",
Applied = "applied", Applied = "applied",
@ -1186,9 +1184,9 @@ export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
*/ */
export type ConfigRecord = export type ConfigRecord =
| { | {
key: typeof WALLET_BACKUP_STATE_KEY; key: typeof WALLET_BACKUP_STATE_KEY;
value: WalletBackupConfState; value: WalletBackupConfState;
} }
| { key: "currencyDefaultsApplied"; value: boolean }; | { key: "currencyDefaultsApplied"; value: boolean };
export interface WalletBackupConfState { export interface WalletBackupConfState {
@ -1405,17 +1403,17 @@ export enum BackupProviderStateTag {
export type BackupProviderState = export type BackupProviderState =
| { | {
tag: BackupProviderStateTag.Provisional; tag: BackupProviderStateTag.Provisional;
} }
| { | {
tag: BackupProviderStateTag.Ready; tag: BackupProviderStateTag.Ready;
nextBackupTimestamp: TalerProtocolTimestamp; nextBackupTimestamp: TalerProtocolTimestamp;
} }
| { | {
tag: BackupProviderStateTag.Retrying; tag: BackupProviderStateTag.Retrying;
retryInfo: RetryInfo; retryInfo: RetryInfo;
lastError?: TalerErrorDetail; lastError?: TalerErrorDetail;
}; };
export interface BackupProviderTerms { export interface BackupProviderTerms {
supportedProtocolVersion: string; supportedProtocolVersion: string;
@ -1625,6 +1623,36 @@ export interface PeerPushPaymentInitiationRecord {
timestampCreated: TalerProtocolTimestamp; timestampCreated: TalerProtocolTimestamp;
} }
export interface PeerPullPaymentInitiationRecord {
/**
* What exchange are we using for the payment request?
*/
exchangeBaseUrl: string;
/**
* Amount requested.
*/
amount: AmountString;
/**
* Purse public key. Used as the primary key to look
* up this record.
*/
pursePub: string;
/**
* Purse private key.
*/
pursePriv: string;
/**
* Contract terms for the other party.
*
* FIXME: Nail down type!
*/
contractTerms: any;
}
/** /**
* Record for a push P2P payment that this wallet was offered. * Record for a push P2P payment that this wallet was offered.
* *
@ -1825,6 +1853,15 @@ export const WalletStoresV1 = {
]), ]),
}, },
), ),
peerPullPaymentInitiation: describeStore(
describeContents<PeerPullPaymentInitiationRecord>(
"peerPushPaymentInitiation",
{
keyPath: "pursePub",
},
),
{},
),
}; };
export interface MetaConfigRecord { export interface MetaConfigRecord {

View File

@ -37,7 +37,10 @@ import {
eddsaGetPublic, eddsaGetPublic,
encodeCrock, encodeCrock,
ExchangePurseMergeRequest, ExchangePurseMergeRequest,
ExchangeReservePurseRequest,
getRandomBytes, getRandomBytes,
InitiatePeerPullPaymentRequest,
InitiatePeerPullPaymentResponse,
InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse, InitiatePeerPushPaymentResponse,
j2s, j2s,
@ -370,6 +373,38 @@ export function talerPaytoFromExchangeReserve(
return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
} }
async function getMergeReserveInfo(
ws: InternalWalletState,
req: {
exchangeBaseUrl: string;
},
): Promise<MergeReserveInfo> {
// We have to eagerly create the key pair outside of the transaction,
// due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
const mergeReserveInfo: MergeReserveInfo = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReserveInfo) {
return ex.currentMergeReserveInfo;
}
await tx.exchanges.put(ex);
ex.currentMergeReserveInfo = {
reservePriv: newReservePair.priv,
reservePub: newReservePair.pub,
};
return ex.currentMergeReserveInfo;
});
return mergeReserveInfo;
}
export async function acceptPeerPushPayment( export async function acceptPeerPushPayment(
ws: InternalWalletState, ws: InternalWalletState,
req: AcceptPeerPushPaymentRequest, req: AcceptPeerPushPaymentRequest,
@ -388,28 +423,9 @@ export async function acceptPeerPushPayment(
const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
// We have to eagerly create the key pair outside of the transaction, const mergeReserveInfo = await getMergeReserveInfo(ws, {
// due to the async crypto API. exchangeBaseUrl: peerInc.exchangeBaseUrl,
const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); });
const mergeReserveInfo: MergeReserveInfo = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReserveInfo) {
return ex.currentMergeReserveInfo;
}
await tx.exchanges.put(ex);
ex.currentMergeReserveInfo = {
reservePriv: newReservePair.priv,
reservePub: newReservePair.pub,
};
return ex.currentMergeReserveInfo;
});
const mergeTimestamp = TalerProtocolTimestamp.now(); const mergeTimestamp = TalerProtocolTimestamp.now();
@ -461,3 +477,115 @@ export async function acceptPeerPushPayment(
}, },
}); });
} }
export async function initiatePeerRequestForPay(
ws: InternalWalletState,
req: InitiatePeerPullPaymentRequest,
): Promise<InitiatePeerPullPaymentResponse> {
const mergeReserveInfo = await getMergeReserveInfo(ws, {
exchangeBaseUrl: req.exchangeBaseUrl,
});
const mergeTimestamp = TalerProtocolTimestamp.now();
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ days: 2 }),
),
);
const reservePayto = talerPaytoFromExchangeReserve(
req.exchangeBaseUrl,
mergeReserveInfo.reservePub,
);
const contractTerms = {
...req.partialContractTerms,
amount: req.amount,
purse_expiration: purseExpiration,
};
const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
contractTerms,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
});
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const purseFee = Amounts.stringify(
Amounts.getZero(Amounts.parseOrThrow(req.amount).currency),
);
const sigRes = await ws.cryptoApi.signReservePurseCreate({
contractTermsHash: hContractTerms,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
mergePriv: mergePair.priv,
mergeTimestamp: mergeTimestamp,
purseAmount: req.amount,
purseExpiration: purseExpiration,
purseFee: purseFee,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
reservePayto,
reservePriv: mergeReserveInfo.reservePriv,
});
await ws.db
.mktx((x) => ({
peerPullPaymentInitiation: x.peerPullPaymentInitiation,
}))
.runReadWrite(async (tx) => {
await tx.peerPullPaymentInitiation.put({
amount: req.amount,
contractTerms,
exchangeBaseUrl: req.exchangeBaseUrl,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
});
});
const reservePurseReqBody: ExchangeReservePurseRequest = {
merge_sig: sigRes.mergeSig,
merge_timestamp: mergeTimestamp,
h_contract_terms: hContractTerms,
merge_pub: mergePair.pub,
min_age: 0,
purse_expiration: purseExpiration,
purse_fee: purseFee,
purse_pub: pursePair.pub,
purse_sig: sigRes.purseSig,
purse_value: req.amount,
reserve_sig: sigRes.accountSig,
econtract: econtractResp.econtract,
};
logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
const reservePurseMergeUrl = new URL(
`reserves/${mergeReserveInfo.reservePub}/purse`,
req.exchangeBaseUrl,
);
const httpResp = await ws.http.postJson(
reservePurseMergeUrl.href,
reservePurseReqBody,
);
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.info(`reserve merge response: ${j2s(resp)}`);
// FIXME: Now create a withdrawal operation!
return {
talerUri: constructPayPushUri({
exchangeBaseUrl: req.exchangeBaseUrl,
contractPriv: econtractResp.contractPriv,
}),
};
}

View File

@ -27,6 +27,7 @@ import {
AcceptExchangeTosRequest, AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest, AcceptManualWithdrawalRequest,
AcceptManualWithdrawalResult, AcceptManualWithdrawalResult,
AcceptPeerPullPaymentRequest,
AcceptPeerPushPaymentRequest, AcceptPeerPushPaymentRequest,
AcceptTipRequest, AcceptTipRequest,
AcceptWithdrawalResponse, AcceptWithdrawalResponse,
@ -35,6 +36,8 @@ import {
ApplyRefundResponse, ApplyRefundResponse,
BackupRecovery, BackupRecovery,
BalancesResponse, BalancesResponse,
CheckPeerPullPaymentRequest,
CheckPeerPullPaymentResponse,
CheckPeerPushPaymentRequest, CheckPeerPushPaymentRequest,
CheckPeerPushPaymentResponse, CheckPeerPushPaymentResponse,
CoinDumpJson, CoinDumpJson,
@ -49,6 +52,8 @@ import {
GetExchangeTosResult, GetExchangeTosResult,
GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest, GetWithdrawalDetailsForUriRequest,
InitiatePeerPullPaymentRequest,
InitiatePeerPullPaymentResponse,
InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse, InitiatePeerPushPaymentResponse,
IntegrationTestArgs, IntegrationTestArgs,
@ -126,6 +131,9 @@ export enum WalletApiOperation {
InitiatePeerPushPayment = "initiatePeerPushPayment", InitiatePeerPushPayment = "initiatePeerPushPayment",
CheckPeerPushPayment = "checkPeerPushPayment", CheckPeerPushPayment = "checkPeerPushPayment",
AcceptPeerPushPayment = "acceptPeerPushPayment", AcceptPeerPushPayment = "acceptPeerPushPayment",
InitiatePeerPullPayment = "initiatePeerPullPayment",
CheckPeerPullPayment = "checkPeerPullPayment",
AcceptPeerPullPayment = "acceptPeerPullPayment",
} }
export type WalletOperations = { export type WalletOperations = {
@ -297,6 +305,18 @@ export type WalletOperations = {
request: AcceptPeerPushPaymentRequest; request: AcceptPeerPushPaymentRequest;
response: {}; response: {};
}; };
[WalletApiOperation.InitiatePeerPullPayment]: {
request: InitiatePeerPullPaymentRequest;
response: InitiatePeerPullPaymentResponse;
};
[WalletApiOperation.CheckPeerPullPayment]: {
request: CheckPeerPullPaymentRequest;
response: CheckPeerPullPaymentResponse;
};
[WalletApiOperation.AcceptPeerPullPayment]: {
request: AcceptPeerPullPaymentRequest;
response: {};
};
}; };
export type RequestType< export type RequestType<

View File

@ -32,6 +32,7 @@ import {
codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest, codecForAcceptExchangeTosRequest,
codecForAcceptManualWithdrawalRequet, codecForAcceptManualWithdrawalRequet,
codecForAcceptPeerPullPaymentRequest,
codecForAcceptPeerPushPaymentRequest, codecForAcceptPeerPushPaymentRequest,
codecForAcceptTipRequest, codecForAcceptTipRequest,
codecForAddExchangeRequest, codecForAddExchangeRequest,
@ -50,6 +51,7 @@ import {
codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri, codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest, codecForImportDbRequest,
codecForInitiatePeerPullPaymentRequest,
codecForInitiatePeerPushPaymentRequest, codecForInitiatePeerPushPaymentRequest,
codecForIntegrationTestArgs, codecForIntegrationTestArgs,
codecForListKnownBankAccounts, codecForListKnownBankAccounts,
@ -150,6 +152,7 @@ import {
import { import {
acceptPeerPushPayment, acceptPeerPushPayment,
checkPeerPushPayment, checkPeerPushPayment,
initiatePeerRequestForPay,
initiatePeerToPeerPush, initiatePeerToPeerPush,
} from "./operations/peer-to-peer.js"; } from "./operations/peer-to-peer.js";
import { getPendingOperations } from "./operations/pending.js"; import { getPendingOperations } from "./operations/pending.js";
@ -455,11 +458,20 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> {
for (const c of builtinAuditors) { for (const c of builtinAuditors) {
await tx.auditorTrustStore.put(c); await tx.auditorTrustStore.put(c);
} }
for (const url of builtinExchanges) {
await updateExchangeFromUrl(ws, url, { forceNow: true });
}
} }
// FIXME: make sure exchanges are added transactionally to
// DB in first-time default application
}); });
for (const url of builtinExchanges) {
try {
await updateExchangeFromUrl(ws, url, { forceNow: true });
} catch (e) {
logger.warn(
`could not update builtin exchange ${url} during wallet initialization`,
);
}
}
} }
async function getExchangeTos( async function getExchangeTos(
@ -568,8 +580,9 @@ async function getExchanges(
continue; continue;
} }
const denominations = await tx.denominations.indexes const denominations = await tx.denominations.indexes.byExchangeBaseUrl
.byExchangeBaseUrl.iter(r.baseUrl).toArray(); .iter(r.baseUrl)
.toArray();
if (!denominations) { if (!denominations) {
continue; continue;
@ -1030,6 +1043,10 @@ async function dispatchRequestInternal(
await acceptPeerPushPayment(ws, req); await acceptPeerPushPayment(ws, req);
return {}; return {};
} }
case "initiatePeerPullPayment": {
const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
return await initiatePeerRequestForPay(ws, req);
}
} }
throw TalerError.fromDetail( throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,