wallet-core/packages/taler-wallet-core/src/operations/pay-peer.ts

1227 lines
36 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
2022-08-09 15:00:45 +02:00
(C) 2022 GNUnet e.V.
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 {
AcceptPeerPullPaymentRequest,
AcceptPeerPullPaymentResponse,
AcceptPeerPushPaymentRequest,
AcceptPeerPushPaymentResponse,
AgeCommitmentProof,
2022-09-16 16:24:47 +02:00
AmountJson,
Amounts,
AmountString,
buildCodecForObject,
CheckPeerPullPaymentRequest,
CheckPeerPullPaymentResponse,
CheckPeerPushPaymentRequest,
CheckPeerPushPaymentResponse,
Codec,
codecForAmountString,
codecForAny,
2022-09-16 16:24:47 +02:00
codecForExchangeGetContractResponse,
CoinStatus,
2022-09-16 16:24:47 +02:00
constructPayPullUri,
2022-08-09 15:00:45 +02:00
constructPayPushUri,
ContractTermsUtil,
decodeCrock,
eddsaGetPublic,
encodeCrock,
ExchangePurseDeposits,
ExchangePurseMergeRequest,
ExchangeReservePurseRequest,
2022-08-09 15:00:45 +02:00
getRandomBytes,
InitiatePeerPullPaymentRequest,
InitiatePeerPullPaymentResponse,
InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse,
j2s,
Logger,
parsePayPullUri,
2022-08-09 15:00:45 +02:00
parsePayPushUri,
PayPeerInsufficientBalanceDetails,
2022-11-02 17:02:42 +01:00
PeerContractTerms,
2022-11-08 17:00:34 +01:00
PreparePeerPullPaymentRequest,
PreparePeerPullPaymentResponse,
PreparePeerPushPaymentRequest,
PreparePeerPushPaymentResponse,
RefreshReason,
strcmp,
TalerErrorCode,
TalerProtocolTimestamp,
TransactionType,
UnblindedSignature,
2022-09-16 16:24:47 +02:00
WalletAccountMergeFlags,
} from "@gnu-taler/taler-util";
import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
import {
DenominationRecord,
2022-11-02 17:02:42 +01:00
OperationStatus,
PeerPullPaymentIncomingStatus,
PeerPushPaymentCoinSelection,
2022-11-02 17:02:42 +01:00
PeerPushPaymentIncomingRecord,
PeerPushPaymentInitiationStatus,
ReserveRecord,
WalletStoresV1,
WithdrawalGroupStatus,
2022-09-16 16:24:47 +02:00
WithdrawalRecordType,
} from "../db.js";
import { TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
makeTransactionId,
runOperationWithErrorReporting,
spendCoins,
} from "../operations/common.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
RetryTags,
} from "../util/retries.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { getTotalRefreshCost } from "./refresh.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/peer-to-peer.ts");
interface SelectedPeerCoin {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
}
interface PeerCoinSelectionDetails {
exchangeBaseUrl: string;
/**
* Info of Coins that were selected.
*/
coins: SelectedPeerCoin[];
/**
* How much of the deposit fees is the customer paying?
*/
depositFees: AmountJson;
}
/**
* Information about a selected coin for peer to peer payments.
*/
interface CoinInfo {
/**
* Public key of the coin.
*/
coinPub: string;
coinPriv: string;
/**
* Deposit fee for the coin.
*/
feeDeposit: AmountJson;
value: AmountJson;
denomPubHash: string;
denomSig: UnblindedSignature;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}
export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelectionDetails }
| {
type: "failure";
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
};
export async function queryCoinInfosForSelection(
ws: InternalWalletState,
csel: PeerPushPaymentCoinSelection,
): Promise<SpendCoinDetails[]> {
let infos: SpendCoinDetails[] = [];
await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (let i = 0; i < csel.coinPubs.length; i++) {
const coin = await tx.coins.get(csel.coinPubs[i]);
if (!coin) {
throw Error("coin not found anymore");
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denom) {
throw Error("denom for coin not found anymore");
}
infos.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
contribution: csel.contributions[i],
});
}
});
return infos;
}
export async function selectPeerCoins(
ws: InternalWalletState,
instructedAmount: AmountJson,
): Promise<SelectPeerCoinsResult> {
return await ws.db
.mktx((x) => [
x.exchanges,
x.contractTerms,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.peerPushPaymentInitiations,
])
.runReadWrite(async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
const exchangeFeeGap: { [url: string]: AmountJson } = {};
const currency = Amounts.currencyOf(instructedAmount);
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== currency) {
continue;
}
const coins = (
await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
).filter((x) => x.status === CoinStatus.Fresh);
const coinInfos: CoinInfo[] = [];
for (const coin of coins) {
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denom) {
throw Error("denom not found");
}
coinInfos.push({
coinPub: coin.coinPub,
feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
value: Amounts.parseOrThrow(denom.value),
denomPubHash: denom.denomPubHash,
coinPriv: coin.coinPriv,
denomSig: coin.denomSig,
maxAge: coin.maxAge,
ageCommitmentProof: coin.ageCommitmentProof,
});
}
if (coinInfos.length === 0) {
continue;
}
coinInfos.sort(
(o1, o2) =>
-Amounts.cmp(o1.value, o2.value) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
let amountAcc = Amounts.zeroOfCurrency(currency);
let depositFeesAcc = Amounts.zeroOfCurrency(currency);
const resCoins: {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
}[] = [];
let lastDepositFee = Amounts.zeroOfCurrency(currency);
for (const coin of coinInfos) {
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
break;
}
const gap = Amounts.add(
coin.feeDeposit,
Amounts.sub(instructedAmount, amountAcc).amount,
).amount;
const contrib = Amounts.min(gap, coin.value);
amountAcc = Amounts.add(
amountAcc,
Amounts.sub(contrib, coin.feeDeposit).amount,
).amount;
depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
resCoins.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contribution: Amounts.stringify(contrib),
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
});
lastDepositFee = coin.feeDeposit;
}
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
const res: PeerCoinSelectionDetails = {
exchangeBaseUrl: exch.baseUrl,
coins: resCoins,
depositFees: depositFeesAcc,
};
return { type: "success", result: res };
}
const diff = Amounts.sub(instructedAmount, amountAcc).amount;
exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
continue;
}
// We were unable to select coins.
// Now we need to produce error details.
const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
currency,
});
const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== currency) {
continue;
}
const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
currency,
restrictExchangeTo: exch.baseUrl,
});
let gap =
exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
// Show fee gap only if we should've been able to pay with the material amount
2023-01-18 16:36:36 +01:00
gap = Amounts.zeroOfCurrency(currency);
}
perExchange[exch.baseUrl] = {
balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
feeGapEstimate: Amounts.stringify(gap),
};
}
const errDetails: PayPeerInsufficientBalanceDetails = {
amountRequested: Amounts.stringify(instructedAmount),
balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
perExchange,
};
return { type: "failure", insufficientBalanceDetails: errDetails };
});
}
export async function getTotalPeerPaymentCost(
ws: InternalWalletState,
pcs: SelectedPeerCoin[],
): Promise<AmountJson> {
return ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
const costs: AmountJson[] = [];
for (let i = 0; i < pcs.length; i++) {
const coin = await tx.coins.get(pcs[i].coinPub);
if (!coin) {
throw Error("can't calculate payment cost, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.filter((x) =>
Amounts.isSameCurrency(
DenominationRecord.getValue(x),
pcs[i].contribution,
),
);
const amountLeft = Amounts.sub(
DenominationRecord.getValue(denom),
pcs[i].contribution,
).amount;
const refreshCost = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
);
costs.push(Amounts.parseOrThrow(pcs[i].contribution));
costs.push(refreshCost);
}
const zero = Amounts.zeroOfAmount(pcs[0].contribution);
return Amounts.sum([zero, ...costs]).amount;
});
}
2022-11-08 17:00:34 +01:00
export async function preparePeerPushPayment(
ws: InternalWalletState,
req: PreparePeerPushPaymentRequest,
): Promise<PreparePeerPushPaymentResponse> {
const instructedAmount = Amounts.parseOrThrow(req.amount);
const coinSelRes = await selectPeerCoins(ws, instructedAmount);
if (coinSelRes.type === "failure") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
2022-11-08 17:00:34 +01:00
return {
amountEffective: Amounts.stringify(totalAmount),
2022-11-08 17:00:34 +01:00
amountRaw: req.amount,
};
}
export async function processPeerPushInitiation(
ws: InternalWalletState,
pursePub: string,
): Promise<OperationAttemptResult> {
const peerPushInitiation = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadOnly(async (tx) => {
return tx.peerPushPaymentInitiations.get(pursePub);
});
if (!peerPushInitiation) {
throw Error("peer push payment not found");
}
const purseExpiration = peerPushInitiation.purseExpiration;
const hContractTerms = peerPushInitiation.contractTermsHash;
const purseSigResp = await ws.cryptoApi.signPurseCreation({
hContractTerms,
mergePub: peerPushInitiation.mergePub,
minAge: 0,
purseAmount: peerPushInitiation.amount,
purseExpiration,
pursePriv: peerPushInitiation.pursePriv,
});
const coins = await queryCoinInfosForSelection(
ws,
peerPushInitiation.coinSel,
);
const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
pursePub: peerPushInitiation.pursePub,
coins,
});
const econtractResp = await ws.cryptoApi.encryptContractForMerge({
contractTerms: peerPushInitiation.contractTerms,
mergePriv: peerPushInitiation.mergePriv,
pursePriv: peerPushInitiation.pursePriv,
pursePub: peerPushInitiation.pursePub,
contractPriv: peerPushInitiation.contractPriv,
contractPub: peerPushInitiation.contractPub,
});
const createPurseUrl = new URL(
`purses/${peerPushInitiation.pursePub}/create`,
peerPushInitiation.exchangeBaseUrl,
);
const httpResp = await ws.http.postJson(createPurseUrl.href, {
amount: peerPushInitiation.amount,
merge_pub: peerPushInitiation.mergePub,
purse_sig: purseSigResp.sig,
h_contract_terms: hContractTerms,
purse_expiration: purseExpiration,
deposits: depositSigsResp.deposits,
min_age: 0,
econtract: econtractResp.econtract,
});
const resp = await httpResp.json();
logger.info(`resp: ${j2s(resp)}`);
if (httpResp.status !== 200) {
throw Error("got error response from exchange");
}
await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const ppi = await tx.peerPushPaymentInitiations.get(pursePub);
if (!ppi) {
return;
}
ppi.status = PeerPushPaymentInitiationStatus.PurseCreated;
await tx.peerPushPaymentInitiations.put(ppi);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
/**
* Initiate sending a peer-to-peer push payment.
*/
export async function initiatePeerPushPayment(
ws: InternalWalletState,
req: InitiatePeerPushPaymentRequest,
): Promise<InitiatePeerPushPaymentResponse> {
2022-11-08 17:00:34 +01:00
const instructedAmount = Amounts.parseOrThrow(
req.partialContractTerms.amount,
);
const purseExpiration = req.partialContractTerms.purse_expiration;
const contractTerms = req.partialContractTerms;
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
const coinSelRes = await selectPeerCoins(ws, instructedAmount);
if (coinSelRes.type !== "success") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const sel = coinSelRes.result;
logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
await ws.db
.mktx((x) => [
x.exchanges,
2022-11-02 17:02:42 +01:00
x.contractTerms,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.peerPushPaymentInitiations,
])
.runReadWrite(async (tx) => {
await spendCoins(ws, tx, {
allocationId: `txn:peer-push-debit:${pursePair.pub}`,
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPush,
});
await tx.peerPushPaymentInitiations.add({
amount: Amounts.stringify(instructedAmount),
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
2022-11-02 17:02:42 +01:00
contractTermsHash: hContractTerms,
exchangeBaseUrl: sel.exchangeBaseUrl,
mergePriv: mergePair.priv,
mergePub: mergePair.pub,
purseExpiration: purseExpiration,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
timestampCreated: TalerProtocolTimestamp.now(),
status: PeerPushPaymentInitiationStatus.Initiated,
contractTerms: contractTerms,
coinSel: {
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) => x.contribution),
},
totalCost: Amounts.stringify(totalAmount),
2022-11-02 17:02:42 +01:00
});
await tx.contractTerms.put({
h: hContractTerms,
contractTermsRaw: contractTerms,
});
});
await runOperationWithErrorReporting(
ws,
RetryTags.byPeerPushPaymentInitiationPursePub(pursePair.pub),
async () => {
return await processPeerPushInitiation(ws, pursePair.pub);
},
);
return {
contractPriv: contractKeyPair.priv,
mergePriv: mergePair.priv,
pursePub: pursePair.pub,
exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
2022-08-09 15:00:45 +02:00
talerUri: constructPayPushUri({
exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
2022-08-09 15:00:45 +02:00
}),
2022-10-14 22:56:29 +02:00
transactionId: makeTransactionId(
TransactionType.PeerPushDebit,
pursePair.pub,
),
};
}
interface ExchangePurseStatus {
balance: AmountString;
}
export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
buildCodecForObject<ExchangePurseStatus>()
.property("balance", codecForAmountString())
.build("ExchangePurseStatus");
export async function checkPeerPushPayment(
ws: InternalWalletState,
req: CheckPeerPushPaymentRequest,
): Promise<CheckPeerPushPaymentResponse> {
2022-08-09 15:00:45 +02:00
// FIXME: Check if existing record exists!
2022-08-09 15:00:45 +02:00
const uri = parsePayPushUri(req.talerUri);
2022-08-09 15:00:45 +02:00
if (!uri) {
throw Error("got invalid taler://pay-push URI");
}
2022-08-09 15:00:45 +02:00
const exchangeBaseUrl = uri.exchangeBaseUrl;
2022-08-24 21:07:09 +02:00
await updateExchangeFromUrl(ws, exchangeBaseUrl);
2022-08-09 15:00:45 +02:00
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
2022-08-09 15:00:45 +02:00
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await ws.http.get(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
2022-08-09 15:00:45 +02:00
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract,
2022-08-09 15:00:45 +02:00
contractPriv: contractPriv,
pursePub: pursePub,
});
2022-08-09 15:00:45 +02:00
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
2022-11-02 17:02:42 +01:00
const contractTermsHash = ContractTermsUtil.hashContractTerms(
dec.contractTerms,
);
await ws.db
2022-11-02 17:02:42 +01:00
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
await tx.peerPushPaymentIncoming.add({
2022-08-09 15:00:45 +02:00
peerPushPaymentIncomingId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
mergePriv: dec.mergePriv,
2022-08-09 15:00:45 +02:00
pursePub: pursePub,
timestamp: TalerProtocolTimestamp.now(),
2022-11-02 17:02:42 +01:00
contractTermsHash,
status: OperationStatus.Finished,
});
await tx.contractTerms.put({
h: contractTermsHash,
contractTermsRaw: dec.contractTerms,
});
});
return {
amount: purseStatus.balance,
contractTerms: dec.contractTerms,
2022-08-09 15:00:45 +02:00
peerPushPaymentIncomingId,
};
}
export function talerPaytoFromExchangeReserve(
exchangeBaseUrl: string,
reservePub: string,
): string {
const url = new URL(exchangeBaseUrl);
let proto: string;
if (url.protocol === "http:") {
2022-08-09 15:00:45 +02:00
proto = "taler-reserve-http";
} else if (url.protocol === "https:") {
2022-08-09 15:00:45 +02:00
proto = "taler-reserve";
} else {
throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
}
let path = url.pathname;
if (!path.endsWith("/")) {
path = path + "/";
}
return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
}
async function getMergeReserveInfo(
ws: InternalWalletState,
req: {
exchangeBaseUrl: string;
},
2022-10-14 22:56:29 +02:00
): Promise<ReserveRecord> {
2022-08-09 15:00:45 +02:00
// We have to eagerly create the key pair outside of the transaction,
// due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
2022-10-14 22:56:29 +02:00
const mergeReserveRecord: ReserveRecord = await ws.db
.mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
2022-10-14 22:56:29 +02:00
if (ex.currentMergeReserveRowId != null) {
const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
checkDbInvariant(!!reserve);
return reserve;
}
2022-10-14 22:56:29 +02:00
const reserve: ReserveRecord = {
reservePriv: newReservePair.priv,
2022-08-09 15:00:45 +02:00
reservePub: newReservePair.pub,
};
2022-10-14 22:56:29 +02:00
const insertResp = await tx.reserves.put(reserve);
checkDbInvariant(typeof insertResp.key === "number");
reserve.rowId = insertResp.key;
ex.currentMergeReserveRowId = reserve.rowId;
await tx.exchanges.put(ex);
return reserve;
});
2022-10-14 22:56:29 +02:00
return mergeReserveRecord;
}
export async function acceptPeerPushPayment(
ws: InternalWalletState,
req: AcceptPeerPushPaymentRequest,
): Promise<AcceptPeerPushPaymentResponse> {
2022-11-02 17:02:42 +01:00
let peerInc: PeerPushPaymentIncomingRecord | undefined;
let contractTerms: PeerContractTerms | undefined;
await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadOnly(async (tx) => {
2022-11-02 17:02:42 +01:00
peerInc = await tx.peerPushPaymentIncoming.get(
req.peerPushPaymentIncomingId,
);
if (!peerInc) {
return;
}
const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
if (ctRec) {
contractTerms = ctRec.contractTermsRaw;
}
});
if (!peerInc) {
throw Error(
`can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`,
);
}
2022-11-02 17:02:42 +01:00
checkDbInvariant(!!contractTerms);
2022-08-24 21:07:09 +02:00
await updateExchangeFromUrl(ws, peerInc.exchangeBaseUrl);
2022-11-02 17:02:42 +01:00
const amount = Amounts.parseOrThrow(contractTerms.amount);
const mergeReserveInfo = await getMergeReserveInfo(ws, {
exchangeBaseUrl: peerInc.exchangeBaseUrl,
});
const mergeTimestamp = TalerProtocolTimestamp.now();
const reservePayto = talerPaytoFromExchangeReserve(
2022-08-09 15:00:45 +02:00
peerInc.exchangeBaseUrl,
mergeReserveInfo.reservePub,
);
const sigRes = await ws.cryptoApi.signPurseMerge({
2022-11-02 17:02:42 +01:00
contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
mergePriv: peerInc.mergePriv,
mergeTimestamp: mergeTimestamp,
purseAmount: Amounts.stringify(amount),
2022-11-02 17:02:42 +01:00
purseExpiration: contractTerms.purse_expiration,
2022-11-02 17:42:14 +01:00
purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
pursePub: peerInc.pursePub,
reservePayto,
2022-08-09 15:00:45 +02:00
reservePriv: mergeReserveInfo.reservePriv,
});
const mergePurseUrl = new URL(
2022-08-09 15:00:45 +02:00
`purses/${peerInc.pursePub}/merge`,
peerInc.exchangeBaseUrl,
);
const mergeReq: ExchangePurseMergeRequest = {
payto_uri: reservePayto,
merge_timestamp: mergeTimestamp,
merge_sig: sigRes.mergeSig,
reserve_sig: sigRes.accountSig,
};
const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
2022-08-09 15:00:45 +02:00
logger.info(`merge request: ${j2s(mergeReq)}`);
const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny());
2022-08-09 15:00:45 +02:00
logger.info(`merge response: ${j2s(res)}`);
const wg = await internalCreateWithdrawalGroup(ws, {
2022-08-09 15:00:45 +02:00
amount,
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPushCredit,
2022-11-02 17:02:42 +01:00
contractTerms,
},
2022-08-09 15:00:45 +02:00
exchangeBaseUrl: peerInc.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.QueryingStatus,
2022-08-09 15:00:45 +02:00
reserveKeyPair: {
priv: mergeReserveInfo.reservePriv,
pub: mergeReserveInfo.reservePub,
},
});
return {
transactionId: makeTransactionId(
TransactionType.PeerPushCredit,
2022-09-16 16:24:47 +02:00
wg.withdrawalGroupId,
),
};
}
export async function acceptPeerPullPayment(
ws: InternalWalletState,
req: AcceptPeerPullPaymentRequest,
): Promise<AcceptPeerPullPaymentResponse> {
const peerPullInc = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId);
});
if (!peerPullInc) {
throw Error(
`can't accept unknown incoming p2p pull payment (${req.peerPullPaymentIncomingId})`,
);
}
const instructedAmount = Amounts.parseOrThrow(
peerPullInc.contractTerms.amount,
);
const coinSelRes = await selectPeerCoins(ws, instructedAmount);
logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
if (coinSelRes.type !== "success") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const sel = coinSelRes.result;
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
await ws.db
.mktx((x) => [
x.exchanges,
x.coins,
x.denominations,
x.refreshGroups,
x.peerPullPaymentIncoming,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
await spendCoins(ws, tx, {
allocationId: `txn:peer-pull-debit:${req.peerPullPaymentIncomingId}`,
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPull,
});
const pi = await tx.peerPullPaymentIncoming.get(
req.peerPullPaymentIncomingId,
);
if (!pi) {
throw Error();
}
2022-11-02 17:02:42 +01:00
pi.status = PeerPullPaymentIncomingStatus.Accepted;
pi.totalCost = Amounts.stringify(totalAmount);
await tx.peerPullPaymentIncoming.put(pi);
});
const pursePub = peerPullInc.pursePub;
const coinSel = coinSelRes.result;
const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
exchangeBaseUrl: coinSel.exchangeBaseUrl,
pursePub,
coins: coinSel.coins,
});
const purseDepositUrl = new URL(
`purses/${pursePub}/deposit`,
coinSel.exchangeBaseUrl,
);
const depositPayload: ExchangePurseDeposits = {
deposits: depositSigsResp.deposits,
};
const httpResp = await ws.http.postJson(purseDepositUrl.href, depositPayload);
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.trace(`purse deposit response: ${j2s(resp)}`);
return {
transactionId: makeTransactionId(
TransactionType.PeerPullDebit,
req.peerPullPaymentIncomingId,
2022-09-16 16:24:47 +02:00
),
};
}
export async function checkPeerPullPayment(
ws: InternalWalletState,
req: CheckPeerPullPaymentRequest,
): Promise<CheckPeerPullPaymentResponse> {
const uri = parsePayPullUri(req.talerUri);
if (!uri) {
throw Error("got invalid taler://pay-push URI");
}
const exchangeBaseUrl = uri.exchangeBaseUrl;
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await ws.http.get(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForDeposit({
ciphertext: contractResp.econtract,
contractPriv: contractPriv,
pursePub: pursePub,
});
const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32));
await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
await tx.peerPullPaymentIncoming.add({
peerPullPaymentIncomingId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
pursePub: pursePub,
timestampCreated: TalerProtocolTimestamp.now(),
contractTerms: dec.contractTerms,
2022-11-02 17:02:42 +01:00
status: PeerPullPaymentIncomingStatus.Proposed,
totalCost: undefined,
});
});
return {
amount: purseStatus.balance,
contractTerms: dec.contractTerms,
peerPullPaymentIncomingId,
};
}
export async function processPeerPullInitiation(
ws: InternalWalletState,
pursePub: string,
): Promise<OperationAttemptResult> {
const pullIni = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentInitiations.get(pursePub);
});
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
if (pullIni.status === OperationStatus.Finished) {
logger.warn("peer pull payment initiation is already finished");
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
const mergeReserve = await ws.db
.mktx((x) => [x.reserves])
.runReadOnly(async (tx) => {
return tx.reserves.get(pullIni.mergeReserveRowId);
});
if (!mergeReserve) {
throw Error("merge reserve for peer pull payment not found in database");
}
const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
const reservePayto = talerPaytoFromExchangeReserve(
pullIni.exchangeBaseUrl,
mergeReserve.reservePub,
);
const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
contractPriv: pullIni.contractPriv,
contractPub: pullIni.contractPub,
contractTerms: pullIni.contractTerms,
pursePriv: pullIni.pursePriv,
pursePub: pullIni.pursePub,
});
const purseExpiration = pullIni.contractTerms.purse_expiration;
const sigRes = await ws.cryptoApi.signReservePurseCreate({
contractTermsHash: pullIni.contractTermsHash,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
mergePriv: pullIni.mergePriv,
mergeTimestamp: pullIni.mergeTimestamp,
purseAmount: pullIni.contractTerms.amount,
purseExpiration: purseExpiration,
purseFee: purseFee,
pursePriv: pullIni.pursePriv,
pursePub: pullIni.pursePub,
reservePayto,
reservePriv: mergeReserve.reservePriv,
});
const reservePurseReqBody: ExchangeReservePurseRequest = {
merge_sig: sigRes.mergeSig,
merge_timestamp: pullIni.mergeTimestamp,
h_contract_terms: pullIni.contractTermsHash,
merge_pub: pullIni.mergePub,
min_age: 0,
purse_expiration: purseExpiration,
purse_fee: purseFee,
purse_pub: pullIni.pursePub,
purse_sig: sigRes.purseSig,
purse_value: pullIni.contractTerms.amount,
reserve_sig: sigRes.accountSig,
econtract: econtractResp.econtract,
};
logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
const reservePurseMergeUrl = new URL(
`reserves/${mergeReserve.reservePub}/purse`,
pullIni.exchangeBaseUrl,
);
const httpResp = await ws.http.postJson(
reservePurseMergeUrl.href,
reservePurseReqBody,
);
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.info(`reserve merge response: ${j2s(resp)}`);
await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pi2 = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pi2) {
return;
}
pi2.status = OperationStatus.Finished;
await tx.peerPullPaymentInitiations.put(pi2);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
2022-11-08 17:00:34 +01:00
export async function preparePeerPullPayment(
ws: InternalWalletState,
req: PreparePeerPullPaymentRequest,
): Promise<PreparePeerPullPaymentResponse> {
//FIXME: look up for exchange details and use purse fee
return {
amountEffective: req.amount,
amountRaw: req.amount,
};
}
/**
* Initiate a peer pull payment.
*/
2022-11-02 17:02:42 +01:00
export async function initiatePeerPullPayment(
ws: InternalWalletState,
req: InitiatePeerPullPaymentRequest,
): Promise<InitiatePeerPullPaymentResponse> {
await updateExchangeFromUrl(ws, req.exchangeBaseUrl);
const mergeReserveInfo = await getMergeReserveInfo(ws, {
exchangeBaseUrl: req.exchangeBaseUrl,
});
const mergeTimestamp = TalerProtocolTimestamp.now();
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
2022-11-08 17:00:34 +01:00
const instructedAmount = Amounts.parseOrThrow(
req.partialContractTerms.amount,
);
2022-11-08 17:00:34 +01:00
const contractTerms = req.partialContractTerms;
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
const mergeReserveRowId = mergeReserveInfo.rowId;
checkDbInvariant(!!mergeReserveRowId);
await ws.db
2022-11-02 17:02:42 +01:00
.mktx((x) => [x.peerPullPaymentInitiations, x.contractTerms])
.runReadWrite(async (tx) => {
await tx.peerPullPaymentInitiations.put({
2022-11-08 17:00:34 +01:00
amount: req.partialContractTerms.amount,
2022-11-02 17:02:42 +01:00
contractTermsHash: hContractTerms,
exchangeBaseUrl: req.exchangeBaseUrl,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
mergePriv: mergePair.priv,
mergePub: mergePair.pub,
status: OperationStatus.Pending,
contractTerms: contractTerms,
mergeTimestamp,
mergeReserveRowId: mergeReserveRowId,
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
2022-11-02 17:02:42 +01:00
});
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: hContractTerms,
});
});
// FIXME: Should we somehow signal to the client
// whether purse creation has failed, or does the client/
// check this asynchronously from the transaction status?
await runOperationWithErrorReporting(
ws,
RetryTags.byPeerPullPaymentInitiationPursePub(pursePair.pub),
async () => {
return processPeerPullInitiation(ws, pursePair.pub);
},
);
const wg = await internalCreateWithdrawalGroup(ws, {
2022-11-08 17:00:34 +01:00
amount: instructedAmount,
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPullCredit,
contractTerms,
contractPriv: contractKeyPair.priv,
},
exchangeBaseUrl: req.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.QueryingStatus,
reserveKeyPair: {
priv: mergeReserveInfo.reservePriv,
pub: mergeReserveInfo.reservePub,
},
});
return {
talerUri: constructPayPullUri({
exchangeBaseUrl: req.exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
}),
transactionId: makeTransactionId(
TransactionType.PeerPullCredit,
2022-09-16 16:24:47 +02:00
wg.withdrawalGroupId,
),
};
}