the giant refactoring: split wallet into multiple parts
This commit is contained in:
parent
aaf7e1338d
commit
e1369ff7e8
@ -27,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^16.4.0",
|
"@types/react": "^16.4.0",
|
||||||
"@types/react-dom": "^16.0.0",
|
"@types/react-dom": "^16.0.0",
|
||||||
|
"@types/chrome": "^0.0.91",
|
||||||
"ava": "^2.4.0",
|
"ava": "^2.4.0",
|
||||||
"awesome-typescript-loader": "^5.2.1",
|
"awesome-typescript-loader": "^5.2.1",
|
||||||
"glob": "^7.1.1",
|
"glob": "^7.1.1",
|
||||||
@ -60,13 +61,10 @@
|
|||||||
"webpack-merge": "^4.2.2"
|
"webpack-merge": "^4.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chrome": "^0.0.91",
|
|
||||||
"@types/urijs": "^1.19.3",
|
|
||||||
"axios": "^0.19.0",
|
"axios": "^0.19.0",
|
||||||
"big-integer": "^1.6.48",
|
"big-integer": "^1.6.48",
|
||||||
"idb-bridge": "^0.0.15",
|
"idb-bridge": "^0.0.15",
|
||||||
"qrcode-generator": "^1.4.3",
|
"qrcode-generator": "^1.4.3",
|
||||||
"source-map-support": "^0.5.12",
|
"source-map-support": "^0.5.12"
|
||||||
"urijs": "^1.18.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,10 +24,10 @@ import {
|
|||||||
DefaultNodeWalletArgs,
|
DefaultNodeWalletArgs,
|
||||||
NodeHttpLib,
|
NodeHttpLib,
|
||||||
} from "../headless/helpers";
|
} from "../headless/helpers";
|
||||||
import { openPromise, OpenedPromise } from "../promiseUtils";
|
import { openPromise, OpenedPromise } from "../util/promiseUtils";
|
||||||
import fs = require("fs");
|
import fs = require("fs");
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { HttpRequestLibrary, HttpResponse } from "../http";
|
import { HttpRequestLibrary, HttpResponse } from "../util/http";
|
||||||
import querystring = require("querystring");
|
import querystring = require("querystring");
|
||||||
|
|
||||||
// @ts-ignore: special built-in module
|
// @ts-ignore: special built-in module
|
||||||
@ -66,7 +66,7 @@ export class AndroidHttpLib implements HttpRequestLibrary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
postJson(url: string, body: any): Promise<import("../http").HttpResponse> {
|
postJson(url: string, body: any): Promise<import("../util/http").HttpResponse> {
|
||||||
if (this.useNfcTunnel) {
|
if (this.useNfcTunnel) {
|
||||||
const myId = this.requestId++;
|
const myId = this.requestId++;
|
||||||
const p = openPromise<HttpResponse>();
|
const p = openPromise<HttpResponse>();
|
||||||
|
@ -22,12 +22,11 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
PlanchetRecord,
|
|
||||||
RefreshSessionRecord,
|
RefreshSessionRecord,
|
||||||
ReserveRecord,
|
ReserveRecord,
|
||||||
TipPlanchet,
|
TipPlanchet,
|
||||||
@ -38,9 +37,9 @@ import { CryptoWorker } from "./cryptoWorker";
|
|||||||
|
|
||||||
import { ContractTerms, PaybackRequest } from "../talerTypes";
|
import { ContractTerms, PaybackRequest } from "../talerTypes";
|
||||||
|
|
||||||
import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult } from "../walletTypes";
|
import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult, PlanchetCreationRequest } from "../walletTypes";
|
||||||
|
|
||||||
import * as timer from "../timer";
|
import * as timer from "../util/timer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of a crypto worker.
|
* State of a crypto worker.
|
||||||
@ -336,10 +335,9 @@ export class CryptoApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createPlanchet(
|
createPlanchet(
|
||||||
denom: DenominationRecord,
|
req: PlanchetCreationRequest
|
||||||
reserve: ReserveRecord,
|
|
||||||
): Promise<PlanchetCreationResult> {
|
): Promise<PlanchetCreationResult> {
|
||||||
return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, denom, reserve);
|
return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
createTipPlanchet(denom: DenominationRecord): Promise<TipPlanchet> {
|
createTipPlanchet(denom: DenominationRecord): Promise<TipPlanchet> {
|
||||||
|
@ -42,11 +42,12 @@ import {
|
|||||||
PayCoinInfo,
|
PayCoinInfo,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
PlanchetCreationResult,
|
PlanchetCreationResult,
|
||||||
|
PlanchetCreationRequest,
|
||||||
} from "../walletTypes";
|
} from "../walletTypes";
|
||||||
import { canonicalJson, getTalerStampSec } from "../helpers";
|
import { canonicalJson, getTalerStampSec } from "../util/helpers";
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import * as Amounts from "../amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import * as timer from "../timer";
|
import * as timer from "../util/timer";
|
||||||
import {
|
import {
|
||||||
getRandomBytes,
|
getRandomBytes,
|
||||||
encodeCrock,
|
encodeCrock,
|
||||||
@ -155,24 +156,23 @@ export class CryptoImplementation {
|
|||||||
* reserve.
|
* reserve.
|
||||||
*/
|
*/
|
||||||
createPlanchet(
|
createPlanchet(
|
||||||
denom: DenominationRecord,
|
req: PlanchetCreationRequest,
|
||||||
reserve: ReserveRecord,
|
|
||||||
): PlanchetCreationResult {
|
): PlanchetCreationResult {
|
||||||
const reservePub = decodeCrock(reserve.reservePub);
|
const reservePub = decodeCrock(req.reservePub);
|
||||||
const reservePriv = decodeCrock(reserve.reservePriv);
|
const reservePriv = decodeCrock(req.reservePriv);
|
||||||
const denomPub = decodeCrock(denom.denomPub);
|
const denomPub = decodeCrock(req.denomPub);
|
||||||
const coinKeyPair = createEddsaKeyPair();
|
const coinKeyPair = createEddsaKeyPair();
|
||||||
const blindingFactor = createBlindingKeySecret();
|
const blindingFactor = createBlindingKeySecret();
|
||||||
const coinPubHash = hash(coinKeyPair.eddsaPub);
|
const coinPubHash = hash(coinKeyPair.eddsaPub);
|
||||||
const ev = rsaBlind(coinPubHash, blindingFactor, denomPub);
|
const ev = rsaBlind(coinPubHash, blindingFactor, denomPub);
|
||||||
const amountWithFee = Amounts.add(denom.value, denom.feeWithdraw).amount;
|
const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount;
|
||||||
const denomPubHash = hash(denomPub);
|
const denomPubHash = hash(denomPub);
|
||||||
const evHash = hash(ev);
|
const evHash = hash(ev);
|
||||||
|
|
||||||
const withdrawRequest = buildSigPS(SignaturePurpose.RESERVE_WITHDRAW)
|
const withdrawRequest = buildSigPS(SignaturePurpose.RESERVE_WITHDRAW)
|
||||||
.put(reservePub)
|
.put(reservePub)
|
||||||
.put(amountToBuffer(amountWithFee))
|
.put(amountToBuffer(amountWithFee))
|
||||||
.put(amountToBuffer(denom.feeWithdraw))
|
.put(amountToBuffer(req.feeWithdraw))
|
||||||
.put(denomPubHash)
|
.put(denomPubHash)
|
||||||
.put(evHash)
|
.put(evHash)
|
||||||
.build();
|
.build();
|
||||||
@ -184,10 +184,9 @@ export class CryptoImplementation {
|
|||||||
coinEv: encodeCrock(ev),
|
coinEv: encodeCrock(ev),
|
||||||
coinPriv: encodeCrock(coinKeyPair.eddsaPriv),
|
coinPriv: encodeCrock(coinKeyPair.eddsaPriv),
|
||||||
coinPub: encodeCrock(coinKeyPair.eddsaPub),
|
coinPub: encodeCrock(coinKeyPair.eddsaPub),
|
||||||
coinValue: denom.value,
|
coinValue: req.value,
|
||||||
denomPub: encodeCrock(denomPub),
|
denomPub: encodeCrock(denomPub),
|
||||||
denomPubHash: encodeCrock(denomPubHash),
|
denomPubHash: encodeCrock(denomPubHash),
|
||||||
exchangeBaseUrl: reserve.exchangeBaseUrl,
|
|
||||||
reservePub: encodeCrock(reservePub),
|
reservePub: encodeCrock(reservePub),
|
||||||
withdrawSig: encodeCrock(sig),
|
withdrawSig: encodeCrock(sig),
|
||||||
};
|
};
|
||||||
|
10
src/db.ts
10
src/db.ts
@ -1,5 +1,5 @@
|
|||||||
import { Stores, WALLET_DB_VERSION } from "./dbTypes";
|
import { Stores, WALLET_DB_VERSION } from "./dbTypes";
|
||||||
import { Store, Index } from "./query";
|
import { Store, Index } from "./util/query";
|
||||||
|
|
||||||
const DB_NAME = "taler";
|
const DB_NAME = "taler";
|
||||||
|
|
||||||
@ -21,9 +21,7 @@ export function openTalerDb(
|
|||||||
req.onsuccess = e => {
|
req.onsuccess = e => {
|
||||||
req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
|
req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
|
||||||
console.log(
|
console.log(
|
||||||
`handling live db version change from ${evt.oldVersion} to ${
|
`handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`,
|
||||||
evt.newVersion
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
req.result.close();
|
req.result.close();
|
||||||
onVersionChange();
|
onVersionChange();
|
||||||
@ -33,9 +31,7 @@ export function openTalerDb(
|
|||||||
req.onupgradeneeded = e => {
|
req.onupgradeneeded = e => {
|
||||||
const db = req.result;
|
const db = req.result;
|
||||||
console.log(
|
console.log(
|
||||||
`DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${
|
`DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${e.newVersion}`,
|
||||||
e.newVersion
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
switch (e.oldVersion) {
|
switch (e.oldVersion) {
|
||||||
case 0: // DB does not exist yet
|
case 0: // DB does not exist yet
|
||||||
|
116
src/dbTypes.ts
116
src/dbTypes.ts
@ -23,8 +23,8 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { AmountJson } from "./amounts";
|
import { AmountJson } from "./util/amounts";
|
||||||
import { Checkable } from "./checkable";
|
import { Checkable } from "./util/checkable";
|
||||||
import {
|
import {
|
||||||
Auditor,
|
Auditor,
|
||||||
CoinPaySig,
|
CoinPaySig,
|
||||||
@ -35,7 +35,7 @@ import {
|
|||||||
TipResponse,
|
TipResponse,
|
||||||
} from "./talerTypes";
|
} from "./talerTypes";
|
||||||
|
|
||||||
import { Index, Store } from "./query";
|
import { Index, Store } from "./util/query";
|
||||||
import { Timestamp, OperationError } from "./walletTypes";
|
import { Timestamp, OperationError } from "./walletTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -444,30 +444,22 @@ export interface ExchangeRecord {
|
|||||||
* A coin that isn't yet signed by an exchange.
|
* A coin that isn't yet signed by an exchange.
|
||||||
*/
|
*/
|
||||||
export interface PlanchetRecord {
|
export interface PlanchetRecord {
|
||||||
withdrawSessionId: string;
|
|
||||||
/**
|
|
||||||
* Index of the coin in the withdrawal session.
|
|
||||||
*/
|
|
||||||
coinIndex: number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public key of the coin.
|
* Public key of the coin.
|
||||||
*/
|
*/
|
||||||
coinPub: string;
|
coinPub: string;
|
||||||
coinPriv: string;
|
coinPriv: string;
|
||||||
|
/**
|
||||||
|
* Public key of the reserve, this might be a reserve not
|
||||||
|
* known to the wallet if the planchet is from a tip.
|
||||||
|
*/
|
||||||
reservePub: string;
|
reservePub: string;
|
||||||
denomPubHash: string;
|
denomPubHash: string;
|
||||||
denomPub: string;
|
denomPub: string;
|
||||||
blindingKey: string;
|
blindingKey: string;
|
||||||
withdrawSig: string;
|
withdrawSig: string;
|
||||||
coinEv: string;
|
coinEv: string;
|
||||||
exchangeBaseUrl: string;
|
|
||||||
coinValue: AmountJson;
|
coinValue: AmountJson;
|
||||||
/**
|
|
||||||
* Set to true if this pre-coin came from a tip.
|
|
||||||
* Until the tip is marked as "accepted", the resulting
|
|
||||||
* coin will not be used for payments.
|
|
||||||
*/
|
|
||||||
isFromTip: boolean;
|
isFromTip: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -511,6 +503,12 @@ export enum CoinStatus {
|
|||||||
Dormant = "dormant",
|
Dormant = "dormant",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CoinSource {
|
||||||
|
Withdraw = "withdraw",
|
||||||
|
Refresh = "refresh",
|
||||||
|
Tip = "tip",
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CoinRecord as stored in the "coins" data store
|
* CoinRecord as stored in the "coins" data store
|
||||||
* of the wallet database.
|
* of the wallet database.
|
||||||
@ -690,11 +688,9 @@ export interface TipRecord {
|
|||||||
exchangeUrl: string;
|
exchangeUrl: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain of the merchant, necessary to uniquely identify the tip since
|
* Base URL of the merchant that is giving us the tip.
|
||||||
* merchants can freely choose the ID and a malicious merchant might cause a
|
|
||||||
* collision.
|
|
||||||
*/
|
*/
|
||||||
merchantDomain: string;
|
merchantBaseUrl: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Planchets, the members included in TipPlanchetDetail will be sent to the
|
* Planchets, the members included in TipPlanchetDetail will be sent to the
|
||||||
@ -702,13 +698,6 @@ export interface TipRecord {
|
|||||||
*/
|
*/
|
||||||
planchets?: TipPlanchet[];
|
planchets?: TipPlanchet[];
|
||||||
|
|
||||||
/**
|
|
||||||
* Coin public keys from the planchets.
|
|
||||||
* This field is redundant and used for indexing the record via
|
|
||||||
* a multi-entry index to look up tip records by coin public key.
|
|
||||||
*/
|
|
||||||
coinPubs: string[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response if the merchant responded,
|
* Response if the merchant responded,
|
||||||
* undefined otherwise.
|
* undefined otherwise.
|
||||||
@ -716,18 +705,21 @@ export interface TipRecord {
|
|||||||
response?: TipResponse[];
|
response?: TipResponse[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifier for the tip, chosen by the merchant.
|
* Tip ID chosen by the wallet.
|
||||||
*/
|
*/
|
||||||
tipId: string;
|
tipId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The merchant's identifier for this tip.
|
||||||
|
*/
|
||||||
|
merchantTipId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL to go to once the tip has been accepted.
|
* URL to go to once the tip has been accepted.
|
||||||
*/
|
*/
|
||||||
nextUrl?: string;
|
nextUrl?: string;
|
||||||
|
|
||||||
timestamp: Timestamp;
|
timestamp: Timestamp;
|
||||||
|
|
||||||
pickupUrl: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -983,13 +975,24 @@ export interface CoinsReturnRecord {
|
|||||||
wire: any;
|
wire: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WithdrawalSourceTip {
|
||||||
|
type: "tip";
|
||||||
|
tipId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawalSourceReserve {
|
||||||
|
type: "reserve";
|
||||||
|
reservePub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve
|
||||||
|
|
||||||
export interface WithdrawalSessionRecord {
|
export interface WithdrawalSessionRecord {
|
||||||
withdrawSessionId: string;
|
withdrawSessionId: string;
|
||||||
|
|
||||||
/**
|
source: WithdrawalSource;
|
||||||
* Reserve that we're withdrawing from.
|
|
||||||
*/
|
exchangeBaseUrl: string;
|
||||||
reservePub: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When was the withdrawal operation started started?
|
* When was the withdrawal operation started started?
|
||||||
@ -1010,15 +1013,12 @@ export interface WithdrawalSessionRecord {
|
|||||||
|
|
||||||
denoms: string[];
|
denoms: string[];
|
||||||
|
|
||||||
|
planchets: (undefined | PlanchetRecord)[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coins in this session that are withdrawn are set to true.
|
* Coins in this session that are withdrawn are set to true.
|
||||||
*/
|
*/
|
||||||
withdrawn: boolean[];
|
withdrawn: boolean[];
|
||||||
|
|
||||||
/**
|
|
||||||
* Coins in this session already have a planchet are set to true.
|
|
||||||
*/
|
|
||||||
planchetCreated: boolean[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankWithdrawUriRecord {
|
export interface BankWithdrawUriRecord {
|
||||||
@ -1071,11 +1071,7 @@ export namespace Stores {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super("proposals", { keyPath: "proposalId" });
|
super("proposals", { keyPath: "proposalId" });
|
||||||
}
|
}
|
||||||
urlIndex = new Index<string, ProposalRecord>(
|
urlIndex = new Index<string, ProposalRecord>(this, "urlIndex", "url");
|
||||||
this,
|
|
||||||
"urlIndex",
|
|
||||||
"url",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PurchasesStore extends Store<PurchaseRecord> {
|
class PurchasesStore extends Store<PurchaseRecord> {
|
||||||
@ -1140,16 +1136,8 @@ export namespace Stores {
|
|||||||
|
|
||||||
class TipsStore extends Store<TipRecord> {
|
class TipsStore extends Store<TipRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("tips", {
|
super("tips", { keyPath: "tipId" });
|
||||||
keyPath: (["tipId", "merchantDomain"] as any) as IDBKeyPath,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
coinPubIndex = new Index<string, TipRecord>(
|
|
||||||
this,
|
|
||||||
"coinPubIndex",
|
|
||||||
"coinPubs",
|
|
||||||
{ multiEntry: true },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SenderWiresStore extends Store<SenderWireRecord> {
|
class SenderWiresStore extends Store<SenderWireRecord> {
|
||||||
@ -1162,11 +1150,6 @@ export namespace Stores {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super("withdrawals", { keyPath: "withdrawSessionId" });
|
super("withdrawals", { keyPath: "withdrawSessionId" });
|
||||||
}
|
}
|
||||||
byReservePub = new Index<string, WithdrawalSessionRecord>(
|
|
||||||
this,
|
|
||||||
"withdrawalsReservePubIndex",
|
|
||||||
"reservePub",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
|
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
|
||||||
@ -1175,24 +1158,6 @@ export namespace Stores {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlanchetsStore extends Store<PlanchetRecord> {
|
|
||||||
constructor() {
|
|
||||||
super("planchets", {
|
|
||||||
keyPath: "coinPub",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
byReservePub = new Index<string, PlanchetRecord>(
|
|
||||||
this,
|
|
||||||
"planchetsReservePubIndex",
|
|
||||||
"reservePub",
|
|
||||||
);
|
|
||||||
byWithdrawalWithIdx = new Index<any, PlanchetRecord>(
|
|
||||||
this,
|
|
||||||
"planchetsByWithdrawalWithIdxIndex",
|
|
||||||
["withdrawSessionId", "coinIndex"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const coins = new CoinsStore();
|
export const coins = new CoinsStore();
|
||||||
export const coinsReturns = new Store<CoinsReturnRecord>("coinsReturns", {
|
export const coinsReturns = new Store<CoinsReturnRecord>("coinsReturns", {
|
||||||
keyPath: "contractTermsHash",
|
keyPath: "contractTermsHash",
|
||||||
@ -1201,7 +1166,6 @@ export namespace Stores {
|
|||||||
export const currencies = new CurrenciesStore();
|
export const currencies = new CurrenciesStore();
|
||||||
export const denominations = new DenominationsStore();
|
export const denominations = new DenominationsStore();
|
||||||
export const exchanges = new ExchangesStore();
|
export const exchanges = new ExchangesStore();
|
||||||
export const planchets = new PlanchetsStore();
|
|
||||||
export const proposals = new ProposalsStore();
|
export const proposals = new ProposalsStore();
|
||||||
export const refresh = new Store<RefreshSessionRecord>("refresh", {
|
export const refresh = new Store<RefreshSessionRecord>("refresh", {
|
||||||
keyPath: "refreshSessionId",
|
keyPath: "refreshSessionId",
|
||||||
|
@ -25,7 +25,6 @@
|
|||||||
*/
|
*/
|
||||||
import Axios from "axios";
|
import Axios from "axios";
|
||||||
import querystring = require("querystring");
|
import querystring = require("querystring");
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
export interface BankUser {
|
export interface BankUser {
|
||||||
username: string;
|
username: string;
|
||||||
@ -50,9 +49,7 @@ export class Bank {
|
|||||||
amount,
|
amount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reqUrl = new URI("api/withdraw-headless-uri")
|
const reqUrl = new URL("api/withdraw-headless-uri", this.bankBaseUrl).href;
|
||||||
.absoluteTo(this.bankBaseUrl)
|
|
||||||
.href();
|
|
||||||
|
|
||||||
const resp = await Axios({
|
const resp = await Axios({
|
||||||
method: "post",
|
method: "post",
|
||||||
@ -82,9 +79,7 @@ export class Bank {
|
|||||||
reservePub: string,
|
reservePub: string,
|
||||||
exchangePaytoUri: string,
|
exchangePaytoUri: string,
|
||||||
) {
|
) {
|
||||||
const reqUrl = new URI("api/withdraw-headless")
|
const reqUrl = new URL("api/withdraw-headless", this.bankBaseUrl).href;
|
||||||
.absoluteTo(this.bankBaseUrl)
|
|
||||||
.href();
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
auth: { type: "basic" },
|
auth: { type: "basic" },
|
||||||
@ -111,7 +106,7 @@ export class Bank {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async registerRandomUser(): Promise<BankUser> {
|
async registerRandomUser(): Promise<BankUser> {
|
||||||
const reqUrl = new URI("api/register").absoluteTo(this.bankBaseUrl).href();
|
const reqUrl = new URL("api/register", this.bankBaseUrl).href;
|
||||||
const randId = makeId(8);
|
const randId = makeId(8);
|
||||||
const bankUser: BankUser = {
|
const bankUser: BankUser = {
|
||||||
username: `testuser-${randId}`,
|
username: `testuser-${randId}`,
|
||||||
|
@ -28,13 +28,13 @@ import { SynchronousCryptoWorkerFactory } from "../crypto/synchronousWorker";
|
|||||||
import { openTalerDb } from "../db";
|
import { openTalerDb } from "../db";
|
||||||
import Axios from "axios";
|
import Axios from "axios";
|
||||||
import querystring = require("querystring");
|
import querystring = require("querystring");
|
||||||
import { HttpRequestLibrary } from "../http";
|
import { HttpRequestLibrary } from "../util/http";
|
||||||
import * as amounts from "../amounts";
|
import * as amounts from "../util/amounts";
|
||||||
import { Bank } from "./bank";
|
import { Bank } from "./bank";
|
||||||
|
|
||||||
import fs = require("fs");
|
import fs = require("fs");
|
||||||
import { NodeCryptoWorkerFactory } from "../crypto/nodeProcessWorker";
|
import { NodeCryptoWorkerFactory } from "../crypto/nodeProcessWorker";
|
||||||
import { Logger } from "../logging";
|
import { Logger } from "../util/logging";
|
||||||
|
|
||||||
const logger = new Logger("helpers.ts");
|
const logger = new Logger("helpers.ts");
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ class ConsoleBadge implements Badge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NodeHttpLib implements HttpRequestLibrary {
|
export class NodeHttpLib implements HttpRequestLibrary {
|
||||||
async get(url: string): Promise<import("../http").HttpResponse> {
|
async get(url: string): Promise<import("../util/http").HttpResponse> {
|
||||||
try {
|
try {
|
||||||
const resp = await Axios({
|
const resp = await Axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
@ -70,7 +70,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
|
|||||||
async postJson(
|
async postJson(
|
||||||
url: string,
|
url: string,
|
||||||
body: any,
|
body: any,
|
||||||
): Promise<import("../http").HttpResponse> {
|
): Promise<import("../util/http").HttpResponse> {
|
||||||
try {
|
try {
|
||||||
const resp = await Axios({
|
const resp = await Axios({
|
||||||
method: "post",
|
method: "post",
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
*/
|
*/
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { CheckPaymentResponse } from "../talerTypes";
|
import { CheckPaymentResponse } from "../talerTypes";
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connection to the *internal* merchant backend.
|
* Connection to the *internal* merchant backend.
|
||||||
@ -35,7 +34,7 @@ export class MerchantBackendConnection {
|
|||||||
reason: string,
|
reason: string,
|
||||||
refundAmount: string,
|
refundAmount: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const reqUrl = new URI("refund").absoluteTo(this.merchantBaseUrl).href();
|
const reqUrl = new URL("refund", this.merchantBaseUrl);
|
||||||
const refundReq = {
|
const refundReq = {
|
||||||
order_id: orderId,
|
order_id: orderId,
|
||||||
reason,
|
reason,
|
||||||
@ -43,7 +42,7 @@ export class MerchantBackendConnection {
|
|||||||
};
|
};
|
||||||
const resp = await axios({
|
const resp = await axios({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: reqUrl,
|
url: reqUrl.href,
|
||||||
data: refundReq,
|
data: refundReq,
|
||||||
responseType: "json",
|
responseType: "json",
|
||||||
headers: {
|
headers: {
|
||||||
@ -64,7 +63,7 @@ export class MerchantBackendConnection {
|
|||||||
constructor(public merchantBaseUrl: string, public apiKey: string) {}
|
constructor(public merchantBaseUrl: string, public apiKey: string) {}
|
||||||
|
|
||||||
async authorizeTip(amount: string, justification: string) {
|
async authorizeTip(amount: string, justification: string) {
|
||||||
const reqUrl = new URI("tip-authorize").absoluteTo(this.merchantBaseUrl).href();
|
const reqUrl = new URL("tip-authorize", this.merchantBaseUrl).href;
|
||||||
const tipReq = {
|
const tipReq = {
|
||||||
amount,
|
amount,
|
||||||
justification,
|
justification,
|
||||||
@ -90,7 +89,7 @@ export class MerchantBackendConnection {
|
|||||||
summary: string,
|
summary: string,
|
||||||
fulfillmentUrl: string,
|
fulfillmentUrl: string,
|
||||||
): Promise<{ orderId: string }> {
|
): Promise<{ orderId: string }> {
|
||||||
const reqUrl = new URI("order").absoluteTo(this.merchantBaseUrl).href();
|
const reqUrl = new URL("order", this.merchantBaseUrl).href;
|
||||||
const orderReq = {
|
const orderReq = {
|
||||||
order: {
|
order: {
|
||||||
amount,
|
amount,
|
||||||
@ -118,9 +117,7 @@ export class MerchantBackendConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkPayment(orderId: string): Promise<CheckPaymentResponse> {
|
async checkPayment(orderId: string): Promise<CheckPaymentResponse> {
|
||||||
const reqUrl = new URI("check-payment")
|
const reqUrl = new URL("check-payment", this.merchantBaseUrl).href;
|
||||||
.absoluteTo(this.merchantBaseUrl)
|
|
||||||
.href();
|
|
||||||
const resp = await axios({
|
const resp = await axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: reqUrl,
|
url: reqUrl,
|
||||||
|
@ -23,8 +23,8 @@ import { Wallet, OperationFailedAndReportedError } from "../wallet";
|
|||||||
import qrcodeGenerator = require("qrcode-generator");
|
import qrcodeGenerator = require("qrcode-generator");
|
||||||
import * as clk from "./clk";
|
import * as clk from "./clk";
|
||||||
import { BridgeIDBFactory, MemoryBackend } from "idb-bridge";
|
import { BridgeIDBFactory, MemoryBackend } from "idb-bridge";
|
||||||
import { Logger } from "../logging";
|
import { Logger } from "../util/logging";
|
||||||
import * as Amounts from "../amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import { decodeCrock } from "../crypto/talerCrypto";
|
import { decodeCrock } from "../crypto/talerCrypto";
|
||||||
import { Bank } from "./bank";
|
import { Bank } from "./bank";
|
||||||
|
|
||||||
@ -93,7 +93,6 @@ async function doPay(
|
|||||||
function applyVerbose(verbose: boolean) {
|
function applyVerbose(verbose: boolean) {
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log("enabled verbose logging");
|
console.log("enabled verbose logging");
|
||||||
Wallet.enableTracing = true;
|
|
||||||
BridgeIDBFactory.enableTracing = true;
|
BridgeIDBFactory.enableTracing = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,7 +216,7 @@ walletCli
|
|||||||
} else if (uri.startsWith("taler://tip/")) {
|
} else if (uri.startsWith("taler://tip/")) {
|
||||||
const res = await wallet.getTipStatus(uri);
|
const res = await wallet.getTipStatus(uri);
|
||||||
console.log("tip status", res);
|
console.log("tip status", res);
|
||||||
await wallet.acceptTip(uri);
|
await wallet.acceptTip(res.tipId);
|
||||||
} else if (uri.startsWith("taler://refund/")) {
|
} else if (uri.startsWith("taler://refund/")) {
|
||||||
await wallet.applyRefund(uri);
|
await wallet.applyRefund(uri);
|
||||||
} else if (uri.startsWith("taler://withdraw/")) {
|
} else if (uri.startsWith("taler://withdraw/")) {
|
||||||
|
@ -26,11 +26,11 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { Checkable } from "./checkable";
|
import { Checkable } from "./util/checkable";
|
||||||
|
|
||||||
import * as Amounts from "./amounts";
|
import * as Amounts from "./util/amounts";
|
||||||
|
|
||||||
import { timestampCheck } from "./helpers";
|
import { timestampCheck } from "./util/helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Denomination as found in the /keys response from the exchange.
|
* Denomination as found in the /keys response from the exchange.
|
||||||
|
@ -15,12 +15,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import test from "ava";
|
import test from "ava";
|
||||||
import * as Amounts from "./amounts";
|
import * as Amounts from "./util/amounts";
|
||||||
import { ContractTerms } from "./talerTypes";
|
import { ContractTerms } from "./talerTypes";
|
||||||
|
|
||||||
const amt = (value: number, fraction: number, currency: string): Amounts.AmountJson => ({value, fraction, currency});
|
const amt = (
|
||||||
|
value: number,
|
||||||
|
fraction: number,
|
||||||
|
currency: string,
|
||||||
|
): Amounts.AmountJson => ({ value, fraction, currency });
|
||||||
|
|
||||||
test("amount addition (simple)", (t) => {
|
test("amount addition (simple)", t => {
|
||||||
const a1 = amt(1, 0, "EUR");
|
const a1 = amt(1, 0, "EUR");
|
||||||
const a2 = amt(1, 0, "EUR");
|
const a2 = amt(1, 0, "EUR");
|
||||||
const a3 = amt(2, 0, "EUR");
|
const a3 = amt(2, 0, "EUR");
|
||||||
@ -28,14 +32,14 @@ test("amount addition (simple)", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("amount addition (saturation)", (t) => {
|
test("amount addition (saturation)", t => {
|
||||||
const a1 = amt(1, 0, "EUR");
|
const a1 = amt(1, 0, "EUR");
|
||||||
const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1);
|
const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1);
|
||||||
t.true(res.saturated);
|
t.true(res.saturated);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("amount subtraction (simple)", (t) => {
|
test("amount subtraction (simple)", t => {
|
||||||
const a1 = amt(2, 5, "EUR");
|
const a1 = amt(2, 5, "EUR");
|
||||||
const a2 = amt(1, 0, "EUR");
|
const a2 = amt(1, 0, "EUR");
|
||||||
const a3 = amt(1, 5, "EUR");
|
const a3 = amt(1, 5, "EUR");
|
||||||
@ -43,7 +47,7 @@ test("amount subtraction (simple)", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("amount subtraction (saturation)", (t) => {
|
test("amount subtraction (saturation)", t => {
|
||||||
const a1 = amt(0, 0, "EUR");
|
const a1 = amt(0, 0, "EUR");
|
||||||
const a2 = amt(1, 0, "EUR");
|
const a2 = amt(1, 0, "EUR");
|
||||||
let res = Amounts.sub(a1, a2);
|
let res = Amounts.sub(a1, a2);
|
||||||
@ -53,8 +57,7 @@ test("amount subtraction (saturation)", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("amount comparison", t => {
|
||||||
test("amount comparison", (t) => {
|
|
||||||
t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0);
|
t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0);
|
||||||
t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1);
|
t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1);
|
||||||
t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1);
|
t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1);
|
||||||
@ -65,18 +68,36 @@ test("amount comparison", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("amount parsing", t => {
|
||||||
test("amount parsing", (t) => {
|
t.is(
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"),
|
Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), amt(0, 0, "TESTKUDOS")),
|
||||||
amt(0, 0, "TESTKUDOS")), 0);
|
0,
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"),
|
);
|
||||||
amt(10, 0, "TESTKUDOS")), 0);
|
t.is(
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.1"),
|
Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), amt(10, 0, "TESTKUDOS")),
|
||||||
amt(0, 10000000, "TESTKUDOS")), 0);
|
0,
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.00000001"),
|
);
|
||||||
amt(0, 1, "TESTKUDOS")), 0);
|
t.is(
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"),
|
Amounts.cmp(
|
||||||
amt(4503599627370496, 99999999, "TESTKUDOS")), 0);
|
Amounts.parseOrThrow("TESTKUDOS:0.1"),
|
||||||
|
amt(0, 10000000, "TESTKUDOS"),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
Amounts.cmp(
|
||||||
|
Amounts.parseOrThrow("TESTKUDOS:0.00000001"),
|
||||||
|
amt(0, 1, "TESTKUDOS"),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
Amounts.cmp(
|
||||||
|
Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"),
|
||||||
|
amt(4503599627370496, 99999999, "TESTKUDOS"),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
t.throws(() => Amounts.parseOrThrow("foo:"));
|
t.throws(() => Amounts.parseOrThrow("foo:"));
|
||||||
t.throws(() => Amounts.parseOrThrow("1.0"));
|
t.throws(() => Amounts.parseOrThrow("1.0"));
|
||||||
t.throws(() => Amounts.parseOrThrow("42"));
|
t.throws(() => Amounts.parseOrThrow("42"));
|
||||||
@ -85,14 +106,18 @@ test("amount parsing", (t) => {
|
|||||||
t.throws(() => Amounts.parseOrThrow("EUR:.42"));
|
t.throws(() => Amounts.parseOrThrow("EUR:.42"));
|
||||||
t.throws(() => Amounts.parseOrThrow("EUR:42."));
|
t.throws(() => Amounts.parseOrThrow("EUR:42."));
|
||||||
t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999"));
|
t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999"));
|
||||||
t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.99999999"),
|
t.is(
|
||||||
amt(0, 99999999, "TESTKUDOS")), 0);
|
Amounts.cmp(
|
||||||
|
Amounts.parseOrThrow("TESTKUDOS:0.99999999"),
|
||||||
|
amt(0, 99999999, "TESTKUDOS"),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991"));
|
t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991"));
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("amount stringification", t => {
|
||||||
test("amount stringification", (t) => {
|
|
||||||
t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0");
|
t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0");
|
||||||
t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94");
|
t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94");
|
||||||
t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1");
|
t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1");
|
||||||
@ -103,13 +128,12 @@ test("amount stringification", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("contract terms validation", t => {
|
||||||
test("contract terms validation", (t) => {
|
|
||||||
const c = {
|
const c = {
|
||||||
H_wire: "123",
|
H_wire: "123",
|
||||||
amount: "EUR:1.5",
|
amount: "EUR:1.5",
|
||||||
auditors: [],
|
auditors: [],
|
||||||
exchanges: [{master_pub: "foo", url: "foo"}],
|
exchanges: [{ master_pub: "foo", url: "foo" }],
|
||||||
fulfillment_url: "foo",
|
fulfillment_url: "foo",
|
||||||
max_fee: "EUR:1.5",
|
max_fee: "EUR:1.5",
|
||||||
merchant_pub: "12345",
|
merchant_pub: "12345",
|
||||||
|
19
src/util/assertUnreachable.ts
Normal file
19
src/util/assertUnreachable.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function assertUnreachable(x: never): never {
|
||||||
|
throw new Error("Didn't expect to get here");
|
||||||
|
}
|
52
src/util/asyncMemo.ts
Normal file
52
src/util/asyncMemo.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MemoEntry<T> {
|
||||||
|
p: Promise<T>;
|
||||||
|
t: number;
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AsyncOpMemo<T> {
|
||||||
|
n = 0;
|
||||||
|
memo: { [k: string]: MemoEntry<T> } = {};
|
||||||
|
put(key: string, p: Promise<T>): Promise<T> {
|
||||||
|
const n = this.n++;
|
||||||
|
this.memo[key] = {
|
||||||
|
p,
|
||||||
|
n,
|
||||||
|
t: new Date().getTime(),
|
||||||
|
};
|
||||||
|
p.finally(() => {
|
||||||
|
const r = this.memo[key];
|
||||||
|
if (r && r.n === n) {
|
||||||
|
delete this.memo[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
find(key: string): Promise<T> | undefined {
|
||||||
|
const res = this.memo[key];
|
||||||
|
const tNow = new Date().getTime();
|
||||||
|
if (res && res.t < tNow - 10 * 1000) {
|
||||||
|
delete this.memo[key];
|
||||||
|
return;
|
||||||
|
} else if (res) {
|
||||||
|
return res.p;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
@ -24,8 +24,7 @@
|
|||||||
import { AmountJson } from "./amounts";
|
import { AmountJson } from "./amounts";
|
||||||
import * as Amounts from "./amounts";
|
import * as Amounts from "./amounts";
|
||||||
|
|
||||||
import URI = require("urijs");
|
import { Timestamp } from "../walletTypes";
|
||||||
import { Timestamp } from "./walletTypes";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show an amount in a form suitable for the user.
|
* Show an amount in a form suitable for the user.
|
||||||
@ -47,11 +46,13 @@ export function canonicalizeBaseUrl(url: string) {
|
|||||||
if (!url.startsWith("http") && !url.startsWith("https")) {
|
if (!url.startsWith("http") && !url.startsWith("https")) {
|
||||||
url = "https://" + url;
|
url = "https://" + url;
|
||||||
}
|
}
|
||||||
const x = new URI(url);
|
const x = new URL(url);
|
||||||
x.path(x.path() + "/").normalizePath();
|
if (!x.pathname.endsWith("/")) {
|
||||||
x.fragment("");
|
x.pathname = x.pathname + "/";
|
||||||
x.query();
|
}
|
||||||
return x.href();
|
x.search = "";
|
||||||
|
x.hash = "";
|
||||||
|
return x.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class Logger {
|
|||||||
info(message: string, ...args: any[]) {
|
info(message: string, ...args: any[]) {
|
||||||
console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args);
|
console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args);
|
||||||
}
|
}
|
||||||
trace(message: string, ...args: any[]) {
|
trace(message: any, ...args: any[]) {
|
||||||
console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args)
|
console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args)
|
||||||
}
|
}
|
||||||
}
|
}
|
31
src/util/payto-test.ts
Normal file
31
src/util/payto-test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from "ava";
|
||||||
|
|
||||||
|
import { parsePaytoUri } from "./payto";
|
||||||
|
|
||||||
|
test("basic payto parsing", (t) => {
|
||||||
|
const r1 = parsePaytoUri("https://example.com/");
|
||||||
|
t.is(r1, undefined);
|
||||||
|
|
||||||
|
const r2 = parsePaytoUri("payto:blabla");
|
||||||
|
t.is(r2, undefined);
|
||||||
|
|
||||||
|
const r3 = parsePaytoUri("payto://x-taler-bank/123");
|
||||||
|
t.is(r3?.targetType, "x-taler-bank");
|
||||||
|
t.is(r3?.targetPath, "123");
|
||||||
|
});
|
54
src/util/payto.ts
Normal file
54
src/util/payto.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PaytoUri {
|
||||||
|
targetType: string;
|
||||||
|
targetPath: string;
|
||||||
|
params: { [name: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function parsePaytoUri(s: string): PaytoUri | undefined {
|
||||||
|
const pfx = "payto://"
|
||||||
|
if (!s.startsWith(pfx)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acct, search] = s.slice(pfx.length).split("?");
|
||||||
|
|
||||||
|
const firstSlashPos = acct.indexOf("/");
|
||||||
|
|
||||||
|
if (firstSlashPos === -1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = acct.slice(0, firstSlashPos);
|
||||||
|
const targetPath = acct.slice(firstSlashPos + 1);
|
||||||
|
|
||||||
|
const params: { [k: string]: string } = {};
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(search || "");
|
||||||
|
|
||||||
|
searchParams.forEach((v, k) => {
|
||||||
|
params[v] = k;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetPath,
|
||||||
|
targetType,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
This file is part of TALER
|
This file is part of GNU Taler
|
||||||
(C) 2019 GNUnet e.V.
|
(C) 2019 GNUnet e.V.
|
||||||
|
|
||||||
GNU Taler is free software; you can redistribute it and/or modify it under the
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
||||||
@ -15,9 +15,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import test from "ava";
|
import test from "ava";
|
||||||
import { parsePayUri, parseWithdrawUri, parseRefundUri, parseTipUri } from "./taleruri";
|
import {
|
||||||
|
parsePayUri,
|
||||||
|
parseWithdrawUri,
|
||||||
|
parseRefundUri,
|
||||||
|
parseTipUri,
|
||||||
|
} from "./taleruri";
|
||||||
|
|
||||||
test("taler pay url parsing: http(s)", (t) => {
|
test("taler pay url parsing: http(s)", t => {
|
||||||
const url1 = "https://example.com/bar?spam=eggs";
|
const url1 = "https://example.com/bar?spam=eggs";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -34,8 +39,7 @@ test("taler pay url parsing: http(s)", (t) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler pay url parsing: wrong scheme", t => {
|
||||||
test("taler pay url parsing: wrong scheme", (t) => {
|
|
||||||
const url1 = "talerfoo://";
|
const url1 = "talerfoo://";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
t.is(r1, undefined);
|
t.is(r1, undefined);
|
||||||
@ -45,8 +49,7 @@ test("taler pay url parsing: wrong scheme", (t) => {
|
|||||||
t.is(r2, undefined);
|
t.is(r2, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler pay url parsing: defaults", t => {
|
||||||
test("taler pay url parsing: defaults", (t) => {
|
|
||||||
const url1 = "taler://pay/example.com/-/-/myorder";
|
const url1 = "taler://pay/example.com/-/-/myorder";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -66,8 +69,7 @@ test("taler pay url parsing: defaults", (t) => {
|
|||||||
t.is(r2.sessionId, "mysession");
|
t.is(r2.sessionId, "mysession");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler pay url parsing: trailing parts", t => {
|
||||||
test("taler pay url parsing: trailing parts", (t) => {
|
|
||||||
const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs";
|
const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -78,49 +80,59 @@ test("taler pay url parsing: trailing parts", (t) => {
|
|||||||
t.is(r1.sessionId, "mysession");
|
t.is(r1.sessionId, "mysession");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler pay url parsing: instance", t => {
|
||||||
test("taler pay url parsing: instance", (t) => {
|
|
||||||
const url1 = "taler://pay/example.com/-/myinst/myorder";
|
const url1 = "taler://pay/example.com/-/myinst/myorder";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.downloadUrl, "https://example.com/public/instances/myinst/proposal?order_id=myorder");
|
t.is(
|
||||||
|
r1.downloadUrl,
|
||||||
|
"https://example.com/public/instances/myinst/proposal?order_id=myorder",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler pay url parsing: path prefix and instance", t => {
|
||||||
test("taler pay url parsing: path prefix and instance", (t) => {
|
|
||||||
const url1 = "taler://pay/example.com/mypfx/myinst/myorder";
|
const url1 = "taler://pay/example.com/mypfx/myinst/myorder";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.downloadUrl, "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder");
|
t.is(
|
||||||
|
r1.downloadUrl,
|
||||||
|
"https://example.com/mypfx/instances/myinst/proposal?order_id=myorder",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("taler pay url parsing: complex path prefix", (t) => {
|
test("taler pay url parsing: complex path prefix", t => {
|
||||||
const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder";
|
const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.downloadUrl, "https://example.com/mypfx/public/proposal?order_id=myorder");
|
t.is(
|
||||||
|
r1.downloadUrl,
|
||||||
|
"https://example.com/mypfx/public/proposal?order_id=myorder",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("taler pay url parsing: complex path prefix and instance", (t) => {
|
test("taler pay url parsing: complex path prefix and instance", t => {
|
||||||
const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder";
|
const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.downloadUrl, "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder");
|
t.is(
|
||||||
|
r1.downloadUrl,
|
||||||
|
"https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("taler pay url parsing: non-https #1", (t) => {
|
test("taler pay url parsing: non-https #1", t => {
|
||||||
const url1 = "taler://pay/example.com/-/-/myorder?insecure=1";
|
const url1 = "taler://pay/example.com/-/-/myorder?insecure=1";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -130,7 +142,7 @@ test("taler pay url parsing: non-https #1", (t) => {
|
|||||||
t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder");
|
t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("taler pay url parsing: non-https #2", (t) => {
|
test("taler pay url parsing: non-https #2", t => {
|
||||||
const url1 = "taler://pay/example.com/-/-/myorder?insecure=2";
|
const url1 = "taler://pay/example.com/-/-/myorder?insecure=2";
|
||||||
const r1 = parsePayUri(url1);
|
const r1 = parsePayUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -140,8 +152,7 @@ test("taler pay url parsing: non-https #2", (t) => {
|
|||||||
t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder");
|
t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler withdraw uri parsing", t => {
|
||||||
test("taler withdraw uri parsing", (t) => {
|
|
||||||
const url1 = "taler://withdraw/bank.example.com/-/12345";
|
const url1 = "taler://withdraw/bank.example.com/-/12345";
|
||||||
const r1 = parseWithdrawUri(url1);
|
const r1 = parseWithdrawUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
@ -151,56 +162,69 @@ test("taler withdraw uri parsing", (t) => {
|
|||||||
t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345");
|
t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler refund uri parsing", t => {
|
||||||
test("taler refund uri parsing", (t) => {
|
|
||||||
const url1 = "taler://refund/merchant.example.com/-/-/1234";
|
const url1 = "taler://refund/merchant.example.com/-/-/1234";
|
||||||
const r1 = parseRefundUri(url1);
|
const r1 = parseRefundUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.refundUrl, "https://merchant.example.com/public/refund?order_id=1234");
|
t.is(
|
||||||
|
r1.refundUrl,
|
||||||
|
"https://merchant.example.com/public/refund?order_id=1234",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler refund uri parsing with instance", t => {
|
||||||
test("taler refund uri parsing with instance", (t) => {
|
|
||||||
const url1 = "taler://refund/merchant.example.com/-/myinst/1234";
|
const url1 = "taler://refund/merchant.example.com/-/myinst/1234";
|
||||||
const r1 = parseRefundUri(url1);
|
const r1 = parseRefundUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.refundUrl, "https://merchant.example.com/public/instances/myinst/refund?order_id=1234");
|
t.is(
|
||||||
|
r1.refundUrl,
|
||||||
|
"https://merchant.example.com/public/instances/myinst/refund?order_id=1234",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("taler tip pickup uri", (t) => {
|
test("taler tip pickup uri", t => {
|
||||||
const url1 = "taler://tip/merchant.example.com/-/-/tipid";
|
const url1 = "taler://tip/merchant.example.com/-/-/tipid";
|
||||||
const r1 = parseTipUri(url1);
|
const r1 = parseTipUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.tipPickupUrl, "https://merchant.example.com/public/tip-pickup?tip_id=tipid");
|
t.is(
|
||||||
|
r1.merchantBaseUrl,
|
||||||
|
"https://merchant.example.com/public/tip-pickup?tip_id=tipid",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler tip pickup uri with instance", t => {
|
||||||
test("taler tip pickup uri with instance", (t) => {
|
|
||||||
const url1 = "taler://tip/merchant.example.com/-/tipm/tipid";
|
const url1 = "taler://tip/merchant.example.com/-/tipm/tipid";
|
||||||
const r1 = parseTipUri(url1);
|
const r1 = parseTipUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.tipPickupUrl, "https://merchant.example.com/public/instances/tipm/tip-pickup?tip_id=tipid");
|
t.is(
|
||||||
|
r1.merchantBaseUrl,
|
||||||
|
"https://merchant.example.com/public/instances/tipm/",
|
||||||
|
);
|
||||||
|
t.is(r1.merchantTipId, "tipid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("taler tip pickup uri with instance and prefix", t => {
|
||||||
test("taler tip pickup uri with instance and prefix", (t) => {
|
|
||||||
const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid";
|
const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid";
|
||||||
const r1 = parseTipUri(url1);
|
const r1 = parseTipUri(url1);
|
||||||
if (!r1) {
|
if (!r1) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
t.is(r1.tipPickupUrl, "https://merchant.example.com/my/pfx/instances/tipm/tip-pickup?tip_id=tipid");
|
t.is(
|
||||||
|
r1.merchantBaseUrl,
|
||||||
|
"https://merchant.example.com/my/pfx/instances/tipm/",
|
||||||
|
);
|
||||||
|
t.is(r1.merchantTipId, "tipid");
|
||||||
});
|
});
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
This file is part of TALER
|
This file is part of GNU Taler
|
||||||
(C) 2019 GNUnet e.V.
|
(C) 2019 GNUnet e.V.
|
||||||
|
|
||||||
GNU Taler is free software; you can redistribute it and/or modify it under the
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
||||||
@ -14,9 +14,6 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import URI = require("urijs");
|
|
||||||
import { string } from "prop-types";
|
|
||||||
|
|
||||||
export interface PayUriResult {
|
export interface PayUriResult {
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@ -31,58 +28,47 @@ export interface RefundUriResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TipUriResult {
|
export interface TipUriResult {
|
||||||
tipPickupUrl: string;
|
merchantTipId: string;
|
||||||
tipId: string;
|
|
||||||
merchantInstance: string;
|
|
||||||
merchantOrigin: string;
|
merchantOrigin: string;
|
||||||
|
merchantBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
|
export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
|
||||||
const parsedUri = new URI(s);
|
const pfx = "taler://withdraw/";
|
||||||
if (parsedUri.scheme() !== "taler") {
|
if (!s.startsWith(pfx)) {
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (parsedUri.authority() != "withdraw") {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let [host, path, withdrawId] = parsedUri.segmentCoded();
|
const rest = s.substring(pfx.length);
|
||||||
|
|
||||||
|
let [host, path, withdrawId] = rest.split("/");
|
||||||
|
|
||||||
if (path === "-") {
|
if (path === "-") {
|
||||||
path = "/api/withdraw-operation";
|
path = "api/withdraw-operation";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusUrl: new URI({ protocol: "https", hostname: host, path: path })
|
statusUrl: `https://${host}/${path}/${withdrawId}`,
|
||||||
.segmentCoded(withdrawId)
|
|
||||||
.href(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePayUri(s: string): PayUriResult | undefined {
|
export function parsePayUri(s: string): PayUriResult | undefined {
|
||||||
const parsedUri = new URI(s);
|
if (s.startsWith("https://") || s.startsWith("http://")) {
|
||||||
const query: any = parsedUri.query(true);
|
|
||||||
if (parsedUri.scheme() === "http" || parsedUri.scheme() === "https") {
|
|
||||||
return {
|
return {
|
||||||
downloadUrl: s,
|
downloadUrl: s,
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (parsedUri.scheme() != "taler") {
|
const pfx = "taler://pay/";
|
||||||
return undefined;
|
if (!s.startsWith(pfx)) {
|
||||||
}
|
|
||||||
if (parsedUri.authority() != "pay") {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let [
|
const [path, search] = s.slice(pfx.length).split("?");
|
||||||
_,
|
|
||||||
host,
|
let [host, maybePath, maybeInstance, orderId, maybeSessionid] = path.split(
|
||||||
maybePath,
|
"/",
|
||||||
maybeInstance,
|
);
|
||||||
orderId,
|
|
||||||
maybeSessionid,
|
|
||||||
] = parsedUri.path().split("/");
|
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -107,15 +93,16 @@ export function parsePayUri(s: string): PayUriResult | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let protocol = "https";
|
let protocol = "https";
|
||||||
if (query["insecure"] === "1") {
|
const searchParams = new URLSearchParams(search);
|
||||||
|
if (searchParams.get("insecure") === "1") {
|
||||||
protocol = "http";
|
protocol = "http";
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = new URI(
|
const downloadUrl =
|
||||||
protocol + "://" + host + "/" + decodeURIComponent(maybePath) + maybeInstancePath + "proposal",
|
`${protocol}://${host}/` +
|
||||||
)
|
decodeURIComponent(maybePath) +
|
||||||
.addQuery({ order_id: orderId })
|
maybeInstancePath +
|
||||||
.href();
|
`proposal?order_id=${orderId}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
@ -124,15 +111,14 @@ export function parsePayUri(s: string): PayUriResult | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseTipUri(s: string): TipUriResult | undefined {
|
export function parseTipUri(s: string): TipUriResult | undefined {
|
||||||
const parsedUri = new URI(s);
|
const pfx = "taler://tip/";
|
||||||
if (parsedUri.scheme() != "taler") {
|
if (!s.startsWith(pfx)) {
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (parsedUri.authority() != "tip") {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let [_, host, maybePath, maybeInstance, tipId] = parsedUri.path().split("/");
|
const path = s.slice(pfx.length);
|
||||||
|
|
||||||
|
let [host, maybePath, maybeInstance, tipId] = path.split("/");
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -156,34 +142,25 @@ export function parseTipUri(s: string): TipUriResult | undefined {
|
|||||||
maybeInstancePath = `instances/${maybeInstance}/`;
|
maybeInstancePath = `instances/${maybeInstance}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tipPickupUrl = new URI(
|
const merchantBaseUrl = `https://${host}/${maybePath}${maybeInstancePath}`;
|
||||||
"https://" + host + "/" + maybePath + maybeInstancePath + "tip-pickup",
|
|
||||||
).addQuery({ tip_id: tipId }).href();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tipPickupUrl,
|
merchantTipId: tipId,
|
||||||
tipId: tipId,
|
merchantOrigin: new URL(merchantBaseUrl).origin,
|
||||||
merchantInstance: maybeInstance,
|
merchantBaseUrl,
|
||||||
merchantOrigin: new URI(tipPickupUrl).origin(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRefundUri(s: string): RefundUriResult | undefined {
|
export function parseRefundUri(s: string): RefundUriResult | undefined {
|
||||||
const parsedUri = new URI(s);
|
const pfx = "taler://refund/";
|
||||||
if (parsedUri.scheme() != "taler") {
|
|
||||||
return undefined;
|
if (!s.startsWith(pfx)) {
|
||||||
}
|
|
||||||
if (parsedUri.authority() != "refund") {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let [
|
const path = s.slice(pfx.length);
|
||||||
_,
|
|
||||||
host,
|
let [host, maybePath, maybeInstance, orderId] = path.split("/");
|
||||||
maybePath,
|
|
||||||
maybeInstance,
|
|
||||||
orderId,
|
|
||||||
] = parsedUri.path().split("/");
|
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -207,11 +184,16 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
|
|||||||
maybeInstancePath = `instances/${maybeInstance}/`;
|
maybeInstancePath = `instances/${maybeInstance}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refundUrl = new URI(
|
const refundUrl =
|
||||||
"https://" + host + "/" + maybePath + maybeInstancePath + "refund",
|
"https://" +
|
||||||
)
|
host +
|
||||||
.addQuery({ order_id: orderId })
|
"/" +
|
||||||
.href();
|
maybePath +
|
||||||
|
maybeInstancePath +
|
||||||
|
"refund" +
|
||||||
|
"?order_id=" +
|
||||||
|
orderId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refundUrl,
|
refundUrl,
|
||||||
};
|
};
|
@ -25,7 +25,7 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import * as i18n from "./i18n";
|
import * as i18n from "../i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short summary of the wire information.
|
* Short summary of the wire information.
|
144
src/wallet-impl/balance.ts
Normal file
144
src/wallet-impl/balance.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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 {
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryEvent,
|
||||||
|
WalletBalance,
|
||||||
|
WalletBalanceEntry,
|
||||||
|
} from "../walletTypes";
|
||||||
|
import { oneShotIter, runWithWriteTransaction } from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Stores, TipRecord, CoinStatus } from "../dbTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
|
||||||
|
const logger = new Logger("withdraw.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed balance information, sliced by exchange and by currency.
|
||||||
|
*/
|
||||||
|
export async function getBalances(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
): Promise<WalletBalance> {
|
||||||
|
/**
|
||||||
|
* Add amount to a balance field, both for
|
||||||
|
* the slicing by exchange and currency.
|
||||||
|
*/
|
||||||
|
function addTo(
|
||||||
|
balance: WalletBalance,
|
||||||
|
field: keyof WalletBalanceEntry,
|
||||||
|
amount: AmountJson,
|
||||||
|
exchange: string,
|
||||||
|
): void {
|
||||||
|
const z = Amounts.getZero(amount.currency);
|
||||||
|
const balanceIdentity = {
|
||||||
|
available: z,
|
||||||
|
paybackAmount: z,
|
||||||
|
pendingIncoming: z,
|
||||||
|
pendingPayment: z,
|
||||||
|
pendingIncomingDirty: z,
|
||||||
|
pendingIncomingRefresh: z,
|
||||||
|
pendingIncomingWithdraw: z,
|
||||||
|
};
|
||||||
|
let entryCurr = balance.byCurrency[amount.currency];
|
||||||
|
if (!entryCurr) {
|
||||||
|
balance.byCurrency[amount.currency] = entryCurr = {
|
||||||
|
...balanceIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let entryEx = balance.byExchange[exchange];
|
||||||
|
if (!entryEx) {
|
||||||
|
balance.byExchange[exchange] = entryEx = { ...balanceIdentity };
|
||||||
|
}
|
||||||
|
entryCurr[field] = Amounts.add(entryCurr[field], amount).amount;
|
||||||
|
entryEx[field] = Amounts.add(entryEx[field], amount).amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceStore = {
|
||||||
|
byCurrency: {},
|
||||||
|
byExchange: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases],
|
||||||
|
async tx => {
|
||||||
|
await tx.iter(Stores.coins).forEach(c => {
|
||||||
|
if (c.suspended) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c.status === CoinStatus.Fresh) {
|
||||||
|
addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl);
|
||||||
|
}
|
||||||
|
if (c.status === CoinStatus.Dirty) {
|
||||||
|
addTo(
|
||||||
|
balanceStore,
|
||||||
|
"pendingIncoming",
|
||||||
|
c.currentAmount,
|
||||||
|
c.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
addTo(
|
||||||
|
balanceStore,
|
||||||
|
"pendingIncomingDirty",
|
||||||
|
c.currentAmount,
|
||||||
|
c.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await tx.iter(Stores.refresh).forEach(r => {
|
||||||
|
// Don't count finished refreshes, since the refresh already resulted
|
||||||
|
// in coins being added to the wallet.
|
||||||
|
if (r.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addTo(
|
||||||
|
balanceStore,
|
||||||
|
"pendingIncoming",
|
||||||
|
r.valueOutput,
|
||||||
|
r.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
addTo(
|
||||||
|
balanceStore,
|
||||||
|
"pendingIncomingRefresh",
|
||||||
|
r.valueOutput,
|
||||||
|
r.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.iter(Stores.purchases).forEach(t => {
|
||||||
|
if (t.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const c of t.payReq.coins) {
|
||||||
|
addTo(
|
||||||
|
balanceStore,
|
||||||
|
"pendingPayment",
|
||||||
|
Amounts.parseOrThrow(c.contribution),
|
||||||
|
c.exchange_url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.trace("computed balances:", balanceStore);
|
||||||
|
return balanceStore;
|
||||||
|
}
|
401
src/wallet-impl/exchanges.ts
Normal file
401
src/wallet-impl/exchanges.ts
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import {
|
||||||
|
WALLET_CACHE_BREAKER_CLIENT_VERSION,
|
||||||
|
OperationFailedAndReportedError,
|
||||||
|
} from "../wallet";
|
||||||
|
import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes";
|
||||||
|
import { getTimestampNow, OperationError } from "../walletTypes";
|
||||||
|
import {
|
||||||
|
ExchangeRecord,
|
||||||
|
ExchangeUpdateStatus,
|
||||||
|
Stores,
|
||||||
|
DenominationRecord,
|
||||||
|
DenominationStatus,
|
||||||
|
WireFee,
|
||||||
|
} from "../dbTypes";
|
||||||
|
import {
|
||||||
|
canonicalizeBaseUrl,
|
||||||
|
extractTalerStamp,
|
||||||
|
extractTalerStampOrThrow,
|
||||||
|
} from "../util/helpers";
|
||||||
|
import {
|
||||||
|
oneShotGet,
|
||||||
|
oneShotPut,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
oneShotMutate,
|
||||||
|
} from "../util/query";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { parsePaytoUri } from "../util/payto";
|
||||||
|
|
||||||
|
async function denominationRecordFromKeys(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
denomIn: Denomination,
|
||||||
|
): Promise<DenominationRecord> {
|
||||||
|
const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub);
|
||||||
|
const d: DenominationRecord = {
|
||||||
|
denomPub: denomIn.denom_pub,
|
||||||
|
denomPubHash,
|
||||||
|
exchangeBaseUrl,
|
||||||
|
feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
|
||||||
|
feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
|
||||||
|
feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
|
||||||
|
feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
|
||||||
|
isOffered: true,
|
||||||
|
masterSig: denomIn.master_sig,
|
||||||
|
stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit),
|
||||||
|
stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal),
|
||||||
|
stampExpireWithdraw: extractTalerStampOrThrow(
|
||||||
|
denomIn.stamp_expire_withdraw,
|
||||||
|
),
|
||||||
|
stampStart: extractTalerStampOrThrow(denomIn.stamp_start),
|
||||||
|
status: DenominationStatus.Unverified,
|
||||||
|
value: Amounts.parseOrThrow(denomIn.value),
|
||||||
|
};
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setExchangeError(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
baseUrl: string,
|
||||||
|
err: OperationError,
|
||||||
|
): Promise<void> {
|
||||||
|
const mut = (exchange: ExchangeRecord) => {
|
||||||
|
exchange.lastError = err;
|
||||||
|
return exchange;
|
||||||
|
};
|
||||||
|
await oneShotMutate(ws.db, Stores.exchanges, baseUrl, mut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the exchange's /keys and update our database accordingly.
|
||||||
|
*
|
||||||
|
* Exceptions thrown in this method must be caught and reported
|
||||||
|
* in the pending operations.
|
||||||
|
*/
|
||||||
|
async function updateExchangeWithKeys(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existingExchangeRecord = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.exchanges,
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keysUrl = new URL("keys", baseUrl);
|
||||||
|
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
|
||||||
|
|
||||||
|
let keysResp;
|
||||||
|
try {
|
||||||
|
keysResp = await ws.http.get(keysUrl.href);
|
||||||
|
} catch (e) {
|
||||||
|
const m = `Fetching keys failed: ${e.message}`;
|
||||||
|
await setExchangeError(ws, baseUrl, {
|
||||||
|
type: "network",
|
||||||
|
details: {
|
||||||
|
requestUrl: e.config?.url,
|
||||||
|
},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
let exchangeKeysJson: KeysJson;
|
||||||
|
try {
|
||||||
|
exchangeKeysJson = KeysJson.checked(keysResp.responseJson);
|
||||||
|
} catch (e) {
|
||||||
|
const m = `Parsing /keys response failed: ${e.message}`;
|
||||||
|
await setExchangeError(ws, baseUrl, {
|
||||||
|
type: "protocol-violation",
|
||||||
|
details: {},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastUpdateTimestamp = extractTalerStamp(
|
||||||
|
exchangeKeysJson.list_issue_date,
|
||||||
|
);
|
||||||
|
if (!lastUpdateTimestamp) {
|
||||||
|
const m = `Parsing /keys response failed: invalid list_issue_date.`;
|
||||||
|
await setExchangeError(ws, baseUrl, {
|
||||||
|
type: "protocol-violation",
|
||||||
|
details: {},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeKeysJson.denoms.length === 0) {
|
||||||
|
const m = "exchange doesn't offer any denominations";
|
||||||
|
await setExchangeError(ws, baseUrl, {
|
||||||
|
type: "protocol-violation",
|
||||||
|
details: {},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolVersion = exchangeKeysJson.version;
|
||||||
|
if (!protocolVersion) {
|
||||||
|
const m = "outdate exchange, no version in /keys response";
|
||||||
|
await setExchangeError(ws, baseUrl, {
|
||||||
|
type: "protocol-violation",
|
||||||
|
details: {},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
|
||||||
|
.currency;
|
||||||
|
|
||||||
|
const newDenominations = await Promise.all(
|
||||||
|
exchangeKeysJson.denoms.map(d =>
|
||||||
|
denominationRecordFromKeys(ws, baseUrl, d),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.exchanges, Stores.denominations],
|
||||||
|
async tx => {
|
||||||
|
const r = await tx.get(Stores.exchanges, baseUrl);
|
||||||
|
if (!r) {
|
||||||
|
console.warn(`exchange ${baseUrl} no longer present`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.details) {
|
||||||
|
// FIXME: We need to do some consistency checks!
|
||||||
|
}
|
||||||
|
r.details = {
|
||||||
|
auditors: exchangeKeysJson.auditors,
|
||||||
|
currency: currency,
|
||||||
|
lastUpdateTime: lastUpdateTimestamp,
|
||||||
|
masterPublicKey: exchangeKeysJson.master_public_key,
|
||||||
|
protocolVersion: protocolVersion,
|
||||||
|
};
|
||||||
|
r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE;
|
||||||
|
r.lastError = undefined;
|
||||||
|
await tx.put(Stores.exchanges, r);
|
||||||
|
|
||||||
|
for (const newDenom of newDenominations) {
|
||||||
|
const oldDenom = await tx.get(Stores.denominations, [
|
||||||
|
baseUrl,
|
||||||
|
newDenom.denomPub,
|
||||||
|
]);
|
||||||
|
if (oldDenom) {
|
||||||
|
// FIXME: Do consistency check
|
||||||
|
} else {
|
||||||
|
await tx.put(Stores.denominations, newDenom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wire information for an exchange and store it in the database.
|
||||||
|
*
|
||||||
|
* @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
|
||||||
|
*/
|
||||||
|
async function updateExchangeWithWireInfo(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
) {
|
||||||
|
const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
|
||||||
|
if (!exchange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqUrl = new URL("wire", exchangeBaseUrl);
|
||||||
|
reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION)
|
||||||
|
|
||||||
|
const resp = await ws.http.get(reqUrl.href);
|
||||||
|
|
||||||
|
const wiJson = resp.responseJson;
|
||||||
|
if (!wiJson) {
|
||||||
|
throw Error("/wire response malformed");
|
||||||
|
}
|
||||||
|
const wireInfo = ExchangeWireJson.checked(wiJson);
|
||||||
|
const feesForType: { [wireMethod: string]: WireFee[] } = {};
|
||||||
|
for (const wireMethod of Object.keys(wireInfo.fees)) {
|
||||||
|
const feeList: WireFee[] = [];
|
||||||
|
for (const x of wireInfo.fees[wireMethod]) {
|
||||||
|
const startStamp = extractTalerStamp(x.start_date);
|
||||||
|
if (!startStamp) {
|
||||||
|
throw Error("wrong date format");
|
||||||
|
}
|
||||||
|
const endStamp = extractTalerStamp(x.end_date);
|
||||||
|
if (!endStamp) {
|
||||||
|
throw Error("wrong date format");
|
||||||
|
}
|
||||||
|
feeList.push({
|
||||||
|
closingFee: Amounts.parseOrThrow(x.closing_fee),
|
||||||
|
endStamp,
|
||||||
|
sig: x.sig,
|
||||||
|
startStamp,
|
||||||
|
wireFee: Amounts.parseOrThrow(x.wire_fee),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
feesForType[wireMethod] = feeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => {
|
||||||
|
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
|
||||||
|
if (!r) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.wireInfo = {
|
||||||
|
accounts: wireInfo.accounts,
|
||||||
|
feesForType: feesForType,
|
||||||
|
};
|
||||||
|
r.updateStatus = ExchangeUpdateStatus.FINISHED;
|
||||||
|
r.lastError = undefined;
|
||||||
|
await tx.put(Stores.exchanges, r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or add exchange DB entry by fetching the /keys and /wire information.
|
||||||
|
* Optionally link the reserve entry to the new or existing
|
||||||
|
* exchange entry in then DB.
|
||||||
|
*/
|
||||||
|
export async function updateExchangeFromUrl(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
baseUrl: string,
|
||||||
|
force: boolean = false,
|
||||||
|
): Promise<ExchangeRecord> {
|
||||||
|
const now = getTimestampNow();
|
||||||
|
baseUrl = canonicalizeBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl);
|
||||||
|
if (!r) {
|
||||||
|
const newExchangeRecord: ExchangeRecord = {
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
details: undefined,
|
||||||
|
wireInfo: undefined,
|
||||||
|
updateStatus: ExchangeUpdateStatus.FETCH_KEYS,
|
||||||
|
updateStarted: now,
|
||||||
|
updateReason: "initial",
|
||||||
|
timestampAdded: getTimestampNow(),
|
||||||
|
};
|
||||||
|
await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord);
|
||||||
|
} else {
|
||||||
|
await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => {
|
||||||
|
const rec = await t.get(Stores.exchanges, baseUrl);
|
||||||
|
if (!rec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && force) {
|
||||||
|
rec.updateReason = "forced";
|
||||||
|
}
|
||||||
|
rec.updateStarted = now;
|
||||||
|
rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS;
|
||||||
|
rec.lastError = undefined;
|
||||||
|
t.put(Stores.exchanges, rec);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateExchangeWithKeys(ws, baseUrl);
|
||||||
|
await updateExchangeWithWireInfo(ws, baseUrl);
|
||||||
|
|
||||||
|
const updatedExchange = await oneShotGet(ws.db, Stores.exchanges, baseUrl);
|
||||||
|
|
||||||
|
if (!updatedExchange) {
|
||||||
|
// This should practically never happen
|
||||||
|
throw Error("exchange not found");
|
||||||
|
}
|
||||||
|
return updatedExchange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if and how an exchange is trusted and/or audited.
|
||||||
|
*/
|
||||||
|
export async function getExchangeTrust(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeInfo: ExchangeRecord,
|
||||||
|
): Promise<{ isTrusted: boolean; isAudited: boolean }> {
|
||||||
|
let isTrusted = false;
|
||||||
|
let isAudited = false;
|
||||||
|
const exchangeDetails = exchangeInfo.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
|
||||||
|
}
|
||||||
|
const currencyRecord = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.currencies,
|
||||||
|
exchangeDetails.currency,
|
||||||
|
);
|
||||||
|
if (currencyRecord) {
|
||||||
|
for (const trustedExchange of currencyRecord.exchanges) {
|
||||||
|
if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) {
|
||||||
|
isTrusted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const trustedAuditor of currencyRecord.auditors) {
|
||||||
|
for (const exchangeAuditor of exchangeDetails.auditors) {
|
||||||
|
if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) {
|
||||||
|
isAudited = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { isTrusted, isAudited };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExchangePaytoUri(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
supportedTargetTypes: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
// We do the update here, since the exchange might not even exist
|
||||||
|
// yet in our database.
|
||||||
|
const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl);
|
||||||
|
if (!exchangeRecord) {
|
||||||
|
throw Error(`Exchange '${exchangeBaseUrl}' not found.`);
|
||||||
|
}
|
||||||
|
const exchangeWireInfo = exchangeRecord.wireInfo;
|
||||||
|
if (!exchangeWireInfo) {
|
||||||
|
throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`);
|
||||||
|
}
|
||||||
|
for (let account of exchangeWireInfo.accounts) {
|
||||||
|
const res = parsePaytoUri(account.url);
|
||||||
|
if (!res) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (supportedTargetTypes.includes(res.targetType)) {
|
||||||
|
return account.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Error("no matching exchange account found");
|
||||||
|
}
|
172
src/wallet-impl/history.ts
Normal file
172
src/wallet-impl/history.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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 { HistoryQuery, HistoryEvent } from "../walletTypes";
|
||||||
|
import { oneShotIter } from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Stores, TipRecord } from "../dbTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrive the full event history for this wallet.
|
||||||
|
*/
|
||||||
|
export async function getHistory(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
historyQuery?: HistoryQuery,
|
||||||
|
): Promise<{ history: HistoryEvent[] }> {
|
||||||
|
const history: HistoryEvent[] = [];
|
||||||
|
|
||||||
|
// FIXME: do pagination instead of generating the full history
|
||||||
|
|
||||||
|
// We uniquely identify history rows via their timestamp.
|
||||||
|
// This works as timestamps are guaranteed to be monotonically
|
||||||
|
// increasing even
|
||||||
|
|
||||||
|
const proposals = await oneShotIter(ws.db, Stores.proposals).toArray();
|
||||||
|
for (const p of proposals) {
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
contractTermsHash: p.contractTermsHash,
|
||||||
|
merchantName: p.contractTerms.merchant.name,
|
||||||
|
},
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
type: "claim-order",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawals = await oneShotIter(
|
||||||
|
ws.db,
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
).toArray();
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
withdrawalAmount: w.withdrawalAmount,
|
||||||
|
},
|
||||||
|
timestamp: w.startTimestamp,
|
||||||
|
type: "withdraw",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchases = await oneShotIter(ws.db, Stores.purchases).toArray();
|
||||||
|
for (const p of purchases) {
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
amount: p.contractTerms.amount,
|
||||||
|
contractTermsHash: p.contractTermsHash,
|
||||||
|
fulfillmentUrl: p.contractTerms.fulfillment_url,
|
||||||
|
merchantName: p.contractTerms.merchant.name,
|
||||||
|
},
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
type: "pay",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
if (p.timestamp_refund) {
|
||||||
|
const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount);
|
||||||
|
const amountsPending = Object.keys(p.refundsPending).map(x =>
|
||||||
|
Amounts.parseOrThrow(p.refundsPending[x].refund_amount),
|
||||||
|
);
|
||||||
|
const amountsDone = Object.keys(p.refundsDone).map(x =>
|
||||||
|
Amounts.parseOrThrow(p.refundsDone[x].refund_amount),
|
||||||
|
);
|
||||||
|
const amounts: AmountJson[] = amountsPending.concat(amountsDone);
|
||||||
|
const amount = Amounts.add(
|
||||||
|
Amounts.getZero(contractAmount.currency),
|
||||||
|
...amounts,
|
||||||
|
).amount;
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
contractTermsHash: p.contractTermsHash,
|
||||||
|
fulfillmentUrl: p.contractTerms.fulfillment_url,
|
||||||
|
merchantName: p.contractTerms.merchant.name,
|
||||||
|
refundAmount: amount,
|
||||||
|
},
|
||||||
|
timestamp: p.timestamp_refund,
|
||||||
|
type: "refund",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reserves = await oneShotIter(ws.db, Stores.reserves).toArray();
|
||||||
|
|
||||||
|
for (const r of reserves) {
|
||||||
|
const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual";
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
exchangeBaseUrl: r.exchangeBaseUrl,
|
||||||
|
requestedAmount: Amounts.toString(r.initiallyRequestedAmount),
|
||||||
|
reservePub: r.reservePub,
|
||||||
|
reserveType,
|
||||||
|
bankWithdrawStatusUrl: r.bankWithdrawStatusUrl,
|
||||||
|
},
|
||||||
|
timestamp: r.created,
|
||||||
|
type: "reserve-created",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
if (r.timestampConfirmed) {
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
exchangeBaseUrl: r.exchangeBaseUrl,
|
||||||
|
requestedAmount: Amounts.toString(r.initiallyRequestedAmount),
|
||||||
|
reservePub: r.reservePub,
|
||||||
|
reserveType,
|
||||||
|
bankWithdrawStatusUrl: r.bankWithdrawStatusUrl,
|
||||||
|
},
|
||||||
|
timestamp: r.created,
|
||||||
|
type: "reserve-confirmed",
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tips: TipRecord[] = await oneShotIter(ws.db, Stores.tips).toArray();
|
||||||
|
for (const tip of tips) {
|
||||||
|
history.push({
|
||||||
|
detail: {
|
||||||
|
accepted: tip.accepted,
|
||||||
|
amount: tip.amount,
|
||||||
|
merchantBaseUrl: tip.merchantBaseUrl,
|
||||||
|
tipId: tip.merchantTipId,
|
||||||
|
},
|
||||||
|
timestamp: tip.timestamp,
|
||||||
|
explicit: false,
|
||||||
|
type: "tip",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.exchanges).forEach(exchange => {
|
||||||
|
history.push({
|
||||||
|
type: "exchange-added",
|
||||||
|
explicit: false,
|
||||||
|
timestamp: exchange.timestampAdded,
|
||||||
|
detail: {
|
||||||
|
exchangeBaseUrl: exchange.baseUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms));
|
||||||
|
|
||||||
|
return { history };
|
||||||
|
}
|
822
src/wallet-impl/pay.ts
Normal file
822
src/wallet-impl/pay.ts
Normal file
@ -0,0 +1,822 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import {
|
||||||
|
Auditor,
|
||||||
|
ExchangeHandle,
|
||||||
|
MerchantRefundResponse,
|
||||||
|
PayReq,
|
||||||
|
Proposal,
|
||||||
|
ContractTerms,
|
||||||
|
} from "../talerTypes";
|
||||||
|
import {
|
||||||
|
Timestamp,
|
||||||
|
CoinSelectionResult,
|
||||||
|
CoinWithDenom,
|
||||||
|
PayCoinInfo,
|
||||||
|
getTimestampNow,
|
||||||
|
PreparePayResult,
|
||||||
|
ConfirmPayResult,
|
||||||
|
} from "../walletTypes";
|
||||||
|
import {
|
||||||
|
oneShotIter,
|
||||||
|
oneShotIterIndex,
|
||||||
|
oneShotGet,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
oneShotPut,
|
||||||
|
oneShotGetIndexed,
|
||||||
|
} from "../util/query";
|
||||||
|
import {
|
||||||
|
Stores,
|
||||||
|
CoinStatus,
|
||||||
|
DenominationRecord,
|
||||||
|
ProposalRecord,
|
||||||
|
PurchaseRecord,
|
||||||
|
CoinRecord,
|
||||||
|
ProposalStatus,
|
||||||
|
} from "../dbTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import {
|
||||||
|
amountToPretty,
|
||||||
|
strcmp,
|
||||||
|
extractTalerStamp,
|
||||||
|
canonicalJson,
|
||||||
|
} from "../util/helpers";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { parsePayUri } from "../util/taleruri";
|
||||||
|
import { getTotalRefreshCost, refresh } from "./refresh";
|
||||||
|
import { acceptRefundResponse } from "./refund";
|
||||||
|
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
||||||
|
|
||||||
|
export interface SpeculativePayData {
|
||||||
|
payCoinInfo: PayCoinInfo;
|
||||||
|
exchangeUrl: string;
|
||||||
|
orderDownloadId: string;
|
||||||
|
proposal: ProposalRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoinsForPaymentArgs {
|
||||||
|
allowedAuditors: Auditor[];
|
||||||
|
allowedExchanges: ExchangeHandle[];
|
||||||
|
depositFeeLimit: AmountJson;
|
||||||
|
paymentAmount: AmountJson;
|
||||||
|
wireFeeAmortization: number;
|
||||||
|
wireFeeLimit: AmountJson;
|
||||||
|
wireFeeTime: Timestamp;
|
||||||
|
wireMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectPayCoinsResult {
|
||||||
|
cds: CoinWithDenom[];
|
||||||
|
totalFees: AmountJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger("pay.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select coins for a payment under the merchant's constraints.
|
||||||
|
*
|
||||||
|
* @param denoms all available denoms, used to compute refresh fees
|
||||||
|
*/
|
||||||
|
export function selectPayCoins(
|
||||||
|
denoms: DenominationRecord[],
|
||||||
|
cds: CoinWithDenom[],
|
||||||
|
paymentAmount: AmountJson,
|
||||||
|
depositFeeLimit: AmountJson,
|
||||||
|
): SelectPayCoinsResult | undefined {
|
||||||
|
if (cds.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Sort by ascending deposit fee and denomPub if deposit fee is the same
|
||||||
|
// (to guarantee deterministic results)
|
||||||
|
cds.sort(
|
||||||
|
(o1, o2) =>
|
||||||
|
Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) ||
|
||||||
|
strcmp(o1.denom.denomPub, o2.denom.denomPub),
|
||||||
|
);
|
||||||
|
const currency = cds[0].denom.value.currency;
|
||||||
|
const cdsResult: CoinWithDenom[] = [];
|
||||||
|
let accDepositFee: AmountJson = Amounts.getZero(currency);
|
||||||
|
let accAmount: AmountJson = Amounts.getZero(currency);
|
||||||
|
for (const { coin, denom } of cds) {
|
||||||
|
if (coin.suspended) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coin.status !== CoinStatus.Fresh) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cdsResult.push({ coin, denom });
|
||||||
|
accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount;
|
||||||
|
let leftAmount = Amounts.sub(
|
||||||
|
coin.currentAmount,
|
||||||
|
Amounts.sub(paymentAmount, accAmount).amount,
|
||||||
|
).amount;
|
||||||
|
accAmount = Amounts.add(coin.currentAmount, accAmount).amount;
|
||||||
|
const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0;
|
||||||
|
const coversAmountWithFee =
|
||||||
|
Amounts.cmp(
|
||||||
|
accAmount,
|
||||||
|
Amounts.add(paymentAmount, denom.feeDeposit).amount,
|
||||||
|
) >= 0;
|
||||||
|
const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0;
|
||||||
|
|
||||||
|
logger.trace("candidate coin selection", {
|
||||||
|
coversAmount,
|
||||||
|
isBelowFee,
|
||||||
|
accDepositFee,
|
||||||
|
accAmount,
|
||||||
|
paymentAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((coversAmount && isBelowFee) || coversAmountWithFee) {
|
||||||
|
const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit)
|
||||||
|
.amount;
|
||||||
|
leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount;
|
||||||
|
logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover));
|
||||||
|
let totalFees: AmountJson = Amounts.getZero(currency);
|
||||||
|
if (coversAmountWithFee && !isBelowFee) {
|
||||||
|
// these are the fees the customer has to pay
|
||||||
|
// because the merchant doesn't cover them
|
||||||
|
totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount;
|
||||||
|
}
|
||||||
|
totalFees = Amounts.add(
|
||||||
|
totalFees,
|
||||||
|
getTotalRefreshCost(denoms, denom, leftAmount),
|
||||||
|
).amount;
|
||||||
|
return { cds: cdsResult, totalFees };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchanges and associated coins that are still spendable, but only
|
||||||
|
* if the sum the coins' remaining value covers the payment amount and fees.
|
||||||
|
*/
|
||||||
|
async function getCoinsForPayment(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
args: CoinsForPaymentArgs,
|
||||||
|
): Promise<CoinSelectionResult | undefined> {
|
||||||
|
const {
|
||||||
|
allowedAuditors,
|
||||||
|
allowedExchanges,
|
||||||
|
depositFeeLimit,
|
||||||
|
paymentAmount,
|
||||||
|
wireFeeAmortization,
|
||||||
|
wireFeeLimit,
|
||||||
|
wireFeeTime,
|
||||||
|
wireMethod,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
let remainingAmount = paymentAmount;
|
||||||
|
|
||||||
|
const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray();
|
||||||
|
|
||||||
|
for (const exchange of exchanges) {
|
||||||
|
let isOkay: boolean = false;
|
||||||
|
const exchangeDetails = exchange.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const exchangeFees = exchange.wireInfo;
|
||||||
|
if (!exchangeFees) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// is the exchange explicitly allowed?
|
||||||
|
for (const allowedExchange of allowedExchanges) {
|
||||||
|
if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) {
|
||||||
|
isOkay = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is the exchange allowed because of one of its auditors?
|
||||||
|
if (!isOkay) {
|
||||||
|
for (const allowedAuditor of allowedAuditors) {
|
||||||
|
for (const auditor of exchangeDetails.auditors) {
|
||||||
|
if (auditor.auditor_pub === allowedAuditor.auditor_pub) {
|
||||||
|
isOkay = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOkay) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOkay) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coins = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins.exchangeBaseUrlIndex,
|
||||||
|
exchange.baseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
const denoms = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
exchange.baseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
if (!coins || coins.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denomination of the first coin, we assume that all other
|
||||||
|
// coins have the same currency
|
||||||
|
const firstDenom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
exchange.baseUrl,
|
||||||
|
coins[0].denomPub,
|
||||||
|
]);
|
||||||
|
if (!firstDenom) {
|
||||||
|
throw Error("db inconsistent");
|
||||||
|
}
|
||||||
|
const currency = firstDenom.value.currency;
|
||||||
|
const cds: CoinWithDenom[] = [];
|
||||||
|
for (const coin of coins) {
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
exchange.baseUrl,
|
||||||
|
coin.denomPub,
|
||||||
|
]);
|
||||||
|
if (!denom) {
|
||||||
|
throw Error("db inconsistent");
|
||||||
|
}
|
||||||
|
if (denom.value.currency !== currency) {
|
||||||
|
console.warn(
|
||||||
|
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coin.suspended) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coin.status !== CoinStatus.Fresh) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cds.push({ coin, denom });
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalFees = Amounts.getZero(currency);
|
||||||
|
let wireFee: AmountJson | undefined;
|
||||||
|
for (const fee of exchangeFees.feesForType[wireMethod] || []) {
|
||||||
|
if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) {
|
||||||
|
wireFee = fee.wireFee;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireFee) {
|
||||||
|
const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization);
|
||||||
|
if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) {
|
||||||
|
totalFees = Amounts.add(amortizedWireFee, totalFees).amount;
|
||||||
|
remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
totalFees = Amounts.add(totalFees, res.totalFees).amount;
|
||||||
|
return {
|
||||||
|
cds: res.cds,
|
||||||
|
exchangeUrl: exchange.baseUrl,
|
||||||
|
totalAmount: remainingAmount,
|
||||||
|
totalFees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record all information that is necessary to
|
||||||
|
* pay for a proposal in the wallet's database.
|
||||||
|
*/
|
||||||
|
async function recordConfirmPay(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
proposal: ProposalRecord,
|
||||||
|
payCoinInfo: PayCoinInfo,
|
||||||
|
chosenExchange: string,
|
||||||
|
): Promise<PurchaseRecord> {
|
||||||
|
const payReq: PayReq = {
|
||||||
|
coins: payCoinInfo.sigs,
|
||||||
|
merchant_pub: proposal.contractTerms.merchant_pub,
|
||||||
|
mode: "pay",
|
||||||
|
order_id: proposal.contractTerms.order_id,
|
||||||
|
};
|
||||||
|
const t: PurchaseRecord = {
|
||||||
|
abortDone: false,
|
||||||
|
abortRequested: false,
|
||||||
|
contractTerms: proposal.contractTerms,
|
||||||
|
contractTermsHash: proposal.contractTermsHash,
|
||||||
|
finished: false,
|
||||||
|
lastSessionId: undefined,
|
||||||
|
merchantSig: proposal.merchantSig,
|
||||||
|
payReq,
|
||||||
|
refundsDone: {},
|
||||||
|
refundsPending: {},
|
||||||
|
timestamp: getTimestampNow(),
|
||||||
|
timestamp_refund: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.purchases],
|
||||||
|
async tx => {
|
||||||
|
await tx.put(Stores.purchases, t);
|
||||||
|
for (let c of payCoinInfo.updatedCoins) {
|
||||||
|
await tx.put(Stores.coins, c);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.badge.showNotification();
|
||||||
|
ws.notifier.notify();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextUrl(contractTerms: ContractTerms): string {
|
||||||
|
const fu = new URL(contractTerms.fulfillment_url)
|
||||||
|
fu.searchParams.set("order_id", contractTerms.order_id);
|
||||||
|
return fu.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abortFailedPayment(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
contractTermsHash: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash);
|
||||||
|
if (!purchase) {
|
||||||
|
throw Error("Purchase not found, unable to abort with refund");
|
||||||
|
}
|
||||||
|
if (purchase.finished) {
|
||||||
|
throw Error("Purchase already finished, not aborting");
|
||||||
|
}
|
||||||
|
if (purchase.abortDone) {
|
||||||
|
console.warn("abort requested on already aborted purchase");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
purchase.abortRequested = true;
|
||||||
|
|
||||||
|
// From now on, we can't retry payment anymore,
|
||||||
|
// so mark this in the DB in case the /pay abort
|
||||||
|
// does not complete on the first try.
|
||||||
|
await oneShotPut(ws.db, Stores.purchases, purchase);
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
|
||||||
|
const abortReq = { ...purchase.payReq, mode: "abort-refund" };
|
||||||
|
|
||||||
|
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resp = await ws.http.postJson(payUrl, abortReq);
|
||||||
|
} catch (e) {
|
||||||
|
// Gives the user the option to retry / abort and refresh
|
||||||
|
console.log("aborting payment failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
|
||||||
|
await acceptRefundResponse(ws, refundResponse);
|
||||||
|
|
||||||
|
await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
|
||||||
|
const p = await tx.get(Stores.purchases, purchase.contractTermsHash);
|
||||||
|
if (!p) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p.abortDone = true;
|
||||||
|
await tx.put(Stores.purchases, p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a proposal and store it in the database.
|
||||||
|
* Returns an id for it to retrieve it later.
|
||||||
|
*
|
||||||
|
* @param sessionId Current session ID, if the proposal is being
|
||||||
|
* downloaded in the context of a session ID.
|
||||||
|
*/
|
||||||
|
async function downloadProposal(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
url: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const oldProposal = await oneShotGetIndexed(
|
||||||
|
ws.db,
|
||||||
|
Stores.proposals.urlIndex,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
if (oldProposal) {
|
||||||
|
return oldProposal.proposalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
|
||||||
|
const parsed_url = new URL(url);
|
||||||
|
parsed_url.searchParams.set("nonce", pub);
|
||||||
|
const urlWithNonce = parsed_url.href;
|
||||||
|
console.log("downloading contract from '" + urlWithNonce + "'");
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await ws.http.get(urlWithNonce);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("contract download failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposal = Proposal.checked(resp.responseJson);
|
||||||
|
|
||||||
|
const contractTermsHash = await ws.cryptoApi.hashString(
|
||||||
|
canonicalJson(proposal.contract_terms),
|
||||||
|
);
|
||||||
|
|
||||||
|
const proposalId = encodeCrock(getRandomBytes(32));
|
||||||
|
|
||||||
|
const proposalRecord: ProposalRecord = {
|
||||||
|
contractTerms: proposal.contract_terms,
|
||||||
|
contractTermsHash,
|
||||||
|
merchantSig: proposal.sig,
|
||||||
|
noncePriv: priv,
|
||||||
|
timestamp: getTimestampNow(),
|
||||||
|
url,
|
||||||
|
downloadSessionId: sessionId,
|
||||||
|
proposalId: proposalId,
|
||||||
|
proposalStatus: ProposalStatus.PROPOSED,
|
||||||
|
};
|
||||||
|
await oneShotPut(ws.db, Stores.proposals, proposalRecord);
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
return proposalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPay(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
contractTermsHash: string,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
): Promise<ConfirmPayResult> {
|
||||||
|
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash);
|
||||||
|
if (!purchase) {
|
||||||
|
throw Error("Purchase not found: " + contractTermsHash);
|
||||||
|
}
|
||||||
|
if (purchase.abortRequested) {
|
||||||
|
throw Error("not submitting payment for aborted purchase");
|
||||||
|
}
|
||||||
|
let resp;
|
||||||
|
const payReq = { ...purchase.payReq, session_id: sessionId };
|
||||||
|
|
||||||
|
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resp = await ws.http.postJson(payUrl, payReq);
|
||||||
|
} catch (e) {
|
||||||
|
// Gives the user the option to retry / abort and refresh
|
||||||
|
console.log("payment failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const merchantResp = resp.responseJson;
|
||||||
|
console.log("got success from pay URL");
|
||||||
|
|
||||||
|
const merchantPub = purchase.contractTerms.merchant_pub;
|
||||||
|
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
|
||||||
|
merchantResp.sig,
|
||||||
|
contractTermsHash,
|
||||||
|
merchantPub,
|
||||||
|
);
|
||||||
|
if (!valid) {
|
||||||
|
console.error("merchant payment signature invalid");
|
||||||
|
// FIXME: properly display error
|
||||||
|
throw Error("merchant payment signature invalid");
|
||||||
|
}
|
||||||
|
purchase.finished = true;
|
||||||
|
const modifiedCoins: CoinRecord[] = [];
|
||||||
|
for (const pc of purchase.payReq.coins) {
|
||||||
|
const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub);
|
||||||
|
if (!c) {
|
||||||
|
console.error("coin not found");
|
||||||
|
throw Error("coin used in payment not found");
|
||||||
|
}
|
||||||
|
c.status = CoinStatus.Dirty;
|
||||||
|
modifiedCoins.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.purchases],
|
||||||
|
async tx => {
|
||||||
|
for (let c of modifiedCoins) {
|
||||||
|
tx.put(Stores.coins, c);
|
||||||
|
}
|
||||||
|
tx.put(Stores.purchases, purchase);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of purchase.payReq.coins) {
|
||||||
|
refresh(ws, c.coin_pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = getNextUrl(purchase.contractTerms);
|
||||||
|
ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = {
|
||||||
|
nextUrl,
|
||||||
|
lastSessionId: sessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { nextUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a payment for the given taler://pay/ URI is possible.
|
||||||
|
*
|
||||||
|
* If the payment is possible, the signature are already generated but not
|
||||||
|
* yet send to the merchant.
|
||||||
|
*/
|
||||||
|
export async function preparePay(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerPayUri: string,
|
||||||
|
): Promise<PreparePayResult> {
|
||||||
|
const uriResult = parsePayUri(talerPayUri);
|
||||||
|
|
||||||
|
if (!uriResult) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
error: "URI not supported",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let proposalId: string;
|
||||||
|
try {
|
||||||
|
proposalId = await downloadProposal(
|
||||||
|
ws,
|
||||||
|
uriResult.downloadUrl,
|
||||||
|
uriResult.sessionId,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
error: e.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
|
||||||
|
if (!proposal) {
|
||||||
|
throw Error(`could not get proposal ${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("proposal", proposal);
|
||||||
|
|
||||||
|
const differentPurchase = await oneShotGetIndexed(
|
||||||
|
ws.db,
|
||||||
|
Stores.purchases.fulfillmentUrlIndex,
|
||||||
|
proposal.contractTerms.fulfillment_url,
|
||||||
|
);
|
||||||
|
|
||||||
|
let fulfillmentUrl = proposal.contractTerms.fulfillment_url;
|
||||||
|
let doublePurchaseDetection = false;
|
||||||
|
if (fulfillmentUrl.startsWith("http")) {
|
||||||
|
doublePurchaseDetection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (differentPurchase && doublePurchaseDetection) {
|
||||||
|
// We do this check to prevent merchant B to find out if we bought a
|
||||||
|
// digital product with merchant A by abusing the existing payment
|
||||||
|
// redirect feature.
|
||||||
|
if (
|
||||||
|
differentPurchase.contractTerms.merchant_pub !=
|
||||||
|
proposal.contractTerms.merchant_pub
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"merchant with different public key offered contract with same fulfillment URL as an existing purchase",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (uriResult.sessionId) {
|
||||||
|
await submitPay(
|
||||||
|
ws,
|
||||||
|
differentPurchase.contractTermsHash,
|
||||||
|
uriResult.sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "paid",
|
||||||
|
contractTerms: differentPurchase.contractTerms,
|
||||||
|
nextUrl: getNextUrl(differentPurchase.contractTerms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if we already payed for it.
|
||||||
|
const purchase = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.purchases,
|
||||||
|
proposal.contractTermsHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount);
|
||||||
|
let wireFeeLimit;
|
||||||
|
if (proposal.contractTerms.max_wire_fee) {
|
||||||
|
wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee);
|
||||||
|
} else {
|
||||||
|
wireFeeLimit = Amounts.getZero(paymentAmount.currency);
|
||||||
|
}
|
||||||
|
// If not already payed, check if we could pay for it.
|
||||||
|
const res = await getCoinsForPayment(ws, {
|
||||||
|
allowedAuditors: proposal.contractTerms.auditors,
|
||||||
|
allowedExchanges: proposal.contractTerms.exchanges,
|
||||||
|
depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee),
|
||||||
|
paymentAmount,
|
||||||
|
wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1,
|
||||||
|
wireFeeLimit,
|
||||||
|
// FIXME: parse this properly
|
||||||
|
wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || {
|
||||||
|
t_ms: 0,
|
||||||
|
},
|
||||||
|
wireMethod: proposal.contractTerms.wire_method,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
console.log("not confirming payment, insufficient coins");
|
||||||
|
return {
|
||||||
|
status: "insufficient-balance",
|
||||||
|
contractTerms: proposal.contractTerms,
|
||||||
|
proposalId: proposal.proposalId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create speculative signature if we don't already have one for this proposal
|
||||||
|
if (
|
||||||
|
!ws.speculativePayData ||
|
||||||
|
(ws.speculativePayData &&
|
||||||
|
ws.speculativePayData.orderDownloadId !== proposalId)
|
||||||
|
) {
|
||||||
|
const { exchangeUrl, cds, totalAmount } = res;
|
||||||
|
const payCoinInfo = await ws.cryptoApi.signDeposit(
|
||||||
|
proposal.contractTerms,
|
||||||
|
cds,
|
||||||
|
totalAmount,
|
||||||
|
);
|
||||||
|
ws.speculativePayData = {
|
||||||
|
exchangeUrl,
|
||||||
|
payCoinInfo,
|
||||||
|
proposal,
|
||||||
|
orderDownloadId: proposalId,
|
||||||
|
};
|
||||||
|
logger.trace("created speculative pay data for payment");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "payment-possible",
|
||||||
|
contractTerms: proposal.contractTerms,
|
||||||
|
proposalId: proposal.proposalId,
|
||||||
|
totalFees: res.totalFees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uriResult.sessionId) {
|
||||||
|
await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "paid",
|
||||||
|
contractTerms: proposal.contractTerms,
|
||||||
|
nextUrl: getNextUrl(purchase.contractTerms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the speculative pay data, but only if coins have not changed in between.
|
||||||
|
*/
|
||||||
|
async function getSpeculativePayData(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
proposalId: string,
|
||||||
|
): Promise<SpeculativePayData | undefined> {
|
||||||
|
const sp = ws.speculativePayData;
|
||||||
|
if (!sp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sp.orderDownloadId !== proposalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub);
|
||||||
|
const coins: CoinRecord[] = [];
|
||||||
|
for (let coinKey of coinKeys) {
|
||||||
|
const cc = await oneShotGet(ws.db, Stores.coins, coinKey);
|
||||||
|
if (cc) {
|
||||||
|
coins.push(cc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < coins.length; i++) {
|
||||||
|
const specCoin = sp.payCoinInfo.originalCoins[i];
|
||||||
|
const currentCoin = coins[i];
|
||||||
|
|
||||||
|
// Coin does not exist anymore!
|
||||||
|
if (!currentCoin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a contract to the wallet and sign coins, and send them.
|
||||||
|
*/
|
||||||
|
export async function confirmPay(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
proposalId: string,
|
||||||
|
sessionIdOverride: string | undefined,
|
||||||
|
): Promise<ConfirmPayResult> {
|
||||||
|
logger.trace(
|
||||||
|
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
|
||||||
|
);
|
||||||
|
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
|
||||||
|
|
||||||
|
if (!proposal) {
|
||||||
|
throw Error(`proposal with id ${proposalId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = sessionIdOverride || proposal.downloadSessionId;
|
||||||
|
|
||||||
|
let purchase = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.purchases,
|
||||||
|
proposal.contractTermsHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (purchase) {
|
||||||
|
return submitPay(ws, purchase.contractTermsHash, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount);
|
||||||
|
|
||||||
|
let wireFeeLimit;
|
||||||
|
if (!proposal.contractTerms.max_wire_fee) {
|
||||||
|
wireFeeLimit = Amounts.getZero(contractAmount.currency);
|
||||||
|
} else {
|
||||||
|
wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getCoinsForPayment(ws, {
|
||||||
|
allowedAuditors: proposal.contractTerms.auditors,
|
||||||
|
allowedExchanges: proposal.contractTerms.exchanges,
|
||||||
|
depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee),
|
||||||
|
paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount),
|
||||||
|
wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1,
|
||||||
|
wireFeeLimit,
|
||||||
|
// FIXME: parse this properly
|
||||||
|
wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || {
|
||||||
|
t_ms: 0,
|
||||||
|
},
|
||||||
|
wireMethod: proposal.contractTerms.wire_method,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.trace("coin selection result", res);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
// Should not happen, since checkPay should be called first
|
||||||
|
console.log("not confirming payment, insufficient coins");
|
||||||
|
throw Error("insufficient balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sd = await getSpeculativePayData(ws, proposalId);
|
||||||
|
if (!sd) {
|
||||||
|
const { exchangeUrl, cds, totalAmount } = res;
|
||||||
|
const payCoinInfo = await ws.cryptoApi.signDeposit(
|
||||||
|
proposal.contractTerms,
|
||||||
|
cds,
|
||||||
|
totalAmount,
|
||||||
|
);
|
||||||
|
purchase = await recordConfirmPay(ws, proposal, payCoinInfo, exchangeUrl);
|
||||||
|
} else {
|
||||||
|
purchase = await recordConfirmPay(
|
||||||
|
ws,
|
||||||
|
sd.proposal,
|
||||||
|
sd.payCoinInfo,
|
||||||
|
sd.exchangeUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return submitPay(ws, purchase.contractTermsHash, sessionId);
|
||||||
|
}
|
88
src/wallet-impl/payback.ts
Normal file
88
src/wallet-impl/payback.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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 {
|
||||||
|
oneShotIter,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
oneShotGet,
|
||||||
|
oneShotPut,
|
||||||
|
} from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Stores, TipRecord, CoinStatus } from "../dbTypes";
|
||||||
|
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import { PaybackConfirmation } from "../talerTypes";
|
||||||
|
import { updateExchangeFromUrl } from "./exchanges";
|
||||||
|
|
||||||
|
const logger = new Logger("payback.ts");
|
||||||
|
|
||||||
|
export async function payback(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
coinPub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let coin = await oneShotGet(ws.db, Stores.coins, coinPub);
|
||||||
|
if (!coin) {
|
||||||
|
throw Error(`Coin ${coinPub} not found, can't request payback`);
|
||||||
|
}
|
||||||
|
const reservePub = coin.reservePub;
|
||||||
|
if (!reservePub) {
|
||||||
|
throw Error(`Can't request payback for a refreshed coin`);
|
||||||
|
}
|
||||||
|
const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
if (!reserve) {
|
||||||
|
throw Error(`Reserve of coin ${coinPub} not found`);
|
||||||
|
}
|
||||||
|
switch (coin.status) {
|
||||||
|
case CoinStatus.Dormant:
|
||||||
|
throw Error(`Can't do payback for coin ${coinPub} since it's dormant`);
|
||||||
|
}
|
||||||
|
coin.status = CoinStatus.Dormant;
|
||||||
|
// Even if we didn't get the payback yet, we suspend withdrawal, since
|
||||||
|
// technically we might update reserve status before we get the response
|
||||||
|
// from the reserve for the payback request.
|
||||||
|
reserve.hasPayback = true;
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.reserves],
|
||||||
|
async tx => {
|
||||||
|
await tx.put(Stores.coins, coin!!);
|
||||||
|
await tx.put(Stores.reserves, reserve);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin);
|
||||||
|
const reqUrl = new URL("payback", coin.exchangeBaseUrl);
|
||||||
|
const resp = await ws.http.postJson(reqUrl.href, paybackRequest);
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw Error();
|
||||||
|
}
|
||||||
|
const paybackConfirmation = PaybackConfirmation.checked(resp.responseJson);
|
||||||
|
if (paybackConfirmation.reserve_pub !== coin.reservePub) {
|
||||||
|
throw Error(`Coin's reserve doesn't match reserve on payback`);
|
||||||
|
}
|
||||||
|
coin = await oneShotGet(ws.db, Stores.coins, coinPub);
|
||||||
|
if (!coin) {
|
||||||
|
throw Error(`Coin ${coinPub} not found, can't confirm payback`);
|
||||||
|
}
|
||||||
|
coin.status = CoinStatus.Dormant;
|
||||||
|
await oneShotPut(ws.db, Stores.coins, coin);
|
||||||
|
ws.notifier.notify();
|
||||||
|
await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true);
|
||||||
|
}
|
208
src/wallet-impl/pending.ts
Normal file
208
src/wallet-impl/pending.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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 { PendingOperationInfo, PendingOperationsResponse } from "../walletTypes";
|
||||||
|
import { oneShotIter } from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Stores, ExchangeUpdateStatus, ReserveRecordStatus, CoinStatus, ProposalStatus } from "../dbTypes";
|
||||||
|
|
||||||
|
export async function getPendingOperations(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
): Promise<PendingOperationsResponse> {
|
||||||
|
const pendingOperations: PendingOperationInfo[] = [];
|
||||||
|
const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray();
|
||||||
|
for (let e of exchanges) {
|
||||||
|
switch (e.updateStatus) {
|
||||||
|
case ExchangeUpdateStatus.FINISHED:
|
||||||
|
if (e.lastError) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "bug",
|
||||||
|
message:
|
||||||
|
"Exchange record is in FINISHED state but has lastError set",
|
||||||
|
details: {
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!e.details) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "bug",
|
||||||
|
message:
|
||||||
|
"Exchange record does not have details, but no update in progress.",
|
||||||
|
details: {
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!e.wireInfo) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "bug",
|
||||||
|
message:
|
||||||
|
"Exchange record does not have wire info, but no update in progress.",
|
||||||
|
details: {
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ExchangeUpdateStatus.FETCH_KEYS:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "exchange-update",
|
||||||
|
stage: "fetch-keys",
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
lastError: e.lastError,
|
||||||
|
reason: e.updateReason || "unknown",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ExchangeUpdateStatus.FETCH_WIRE:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "exchange-update",
|
||||||
|
stage: "fetch-wire",
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
lastError: e.lastError,
|
||||||
|
reason: e.updateReason || "unknown",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "bug",
|
||||||
|
message: "Unknown exchangeUpdateStatus",
|
||||||
|
details: {
|
||||||
|
exchangeBaseUrl: e.baseUrl,
|
||||||
|
exchangeUpdateStatus: e.updateStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await oneShotIter(ws.db, Stores.reserves).forEach(reserve => {
|
||||||
|
const reserveType = reserve.bankWithdrawStatusUrl
|
||||||
|
? "taler-bank"
|
||||||
|
: "manual";
|
||||||
|
switch (reserve.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.DORMANT:
|
||||||
|
// nothing to report as pending
|
||||||
|
break;
|
||||||
|
case ReserveRecordStatus.WITHDRAWING:
|
||||||
|
case ReserveRecordStatus.UNCONFIRMED:
|
||||||
|
case ReserveRecordStatus.QUERYING_STATUS:
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "reserve",
|
||||||
|
stage: reserve.reserveStatus,
|
||||||
|
timestampCreated: reserve.created,
|
||||||
|
reserveType,
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "reserve",
|
||||||
|
stage: reserve.reserveStatus,
|
||||||
|
timestampCreated: reserve.created,
|
||||||
|
reserveType,
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "bug",
|
||||||
|
message: "Unknown reserve record status",
|
||||||
|
details: {
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
reserveStatus: reserve.reserveStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.refresh).forEach(r => {
|
||||||
|
if (r.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let refreshStatus: string;
|
||||||
|
if (r.norevealIndex === undefined) {
|
||||||
|
refreshStatus = "melt";
|
||||||
|
} else {
|
||||||
|
refreshStatus = "reveal";
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "refresh",
|
||||||
|
oldCoinPub: r.meltCoinPub,
|
||||||
|
refreshStatus,
|
||||||
|
refreshOutputSize: r.newDenoms.length,
|
||||||
|
refreshSessionId: r.refreshSessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.coins).forEach(coin => {
|
||||||
|
if (coin.status == CoinStatus.Dirty) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "dirty-coin",
|
||||||
|
coinPub: coin.coinPub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.withdrawalSession).forEach(ws => {
|
||||||
|
const numCoinsWithdrawn = ws.withdrawn.reduce(
|
||||||
|
(a, x) => a + (x ? 1 : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const numCoinsTotal = ws.withdrawn.length;
|
||||||
|
if (numCoinsWithdrawn < numCoinsTotal) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "withdraw",
|
||||||
|
numCoinsTotal,
|
||||||
|
numCoinsWithdrawn,
|
||||||
|
source: ws.source,
|
||||||
|
withdrawSessionId: ws.withdrawSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.proposals).forEach(proposal => {
|
||||||
|
if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "proposal",
|
||||||
|
merchantBaseUrl: proposal.contractTerms.merchant_base_url,
|
||||||
|
proposalId: proposal.proposalId,
|
||||||
|
proposalTimestamp: proposal.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await oneShotIter(ws.db, Stores.tips).forEach(tip => {
|
||||||
|
if (tip.accepted && !tip.pickedUp) {
|
||||||
|
pendingOperations.push({
|
||||||
|
type: "tip",
|
||||||
|
merchantBaseUrl: tip.merchantBaseUrl,
|
||||||
|
tipId: tip.tipId,
|
||||||
|
merchantTipId: tip.merchantTipId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingOperations,
|
||||||
|
};
|
||||||
|
}
|
416
src/wallet-impl/refresh.ts
Normal file
416
src/wallet-impl/refresh.ts
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import {
|
||||||
|
DenominationRecord,
|
||||||
|
Stores,
|
||||||
|
CoinStatus,
|
||||||
|
RefreshPlanchetRecord,
|
||||||
|
CoinRecord,
|
||||||
|
RefreshSessionRecord,
|
||||||
|
} from "../dbTypes";
|
||||||
|
import { amountToPretty } from "../util/helpers";
|
||||||
|
import {
|
||||||
|
oneShotGet,
|
||||||
|
oneShotMutate,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
TransactionAbort,
|
||||||
|
oneShotIterIndex,
|
||||||
|
} from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import { getWithdrawDenomList } from "./withdraw";
|
||||||
|
import { updateExchangeFromUrl } from "./exchanges";
|
||||||
|
|
||||||
|
const logger = new Logger("refresh.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the amount that we lose when refreshing a coin of the given denomination
|
||||||
|
* with a certain amount left.
|
||||||
|
*
|
||||||
|
* If the amount left is zero, then the refresh cost
|
||||||
|
* is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
|
||||||
|
* the right denominations), then the cost is the full amount left.
|
||||||
|
*
|
||||||
|
* Considers refresh fees, withdrawal fees after refresh and amounts too small
|
||||||
|
* to refresh.
|
||||||
|
*/
|
||||||
|
export function getTotalRefreshCost(
|
||||||
|
denoms: DenominationRecord[],
|
||||||
|
refreshedDenom: DenominationRecord,
|
||||||
|
amountLeft: AmountJson,
|
||||||
|
): AmountJson {
|
||||||
|
const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh)
|
||||||
|
.amount;
|
||||||
|
const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms);
|
||||||
|
const resultingAmount = Amounts.add(
|
||||||
|
Amounts.getZero(withdrawAmount.currency),
|
||||||
|
...withdrawDenoms.map(d => d.value),
|
||||||
|
).amount;
|
||||||
|
const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
|
||||||
|
logger.trace(
|
||||||
|
"total refresh cost for",
|
||||||
|
amountToPretty(amountLeft),
|
||||||
|
"is",
|
||||||
|
amountToPretty(totalCost),
|
||||||
|
);
|
||||||
|
return totalCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMelt(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
refreshSessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const refreshSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.refresh,
|
||||||
|
refreshSessionId,
|
||||||
|
);
|
||||||
|
if (!refreshSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshSession.norevealIndex !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coin = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins,
|
||||||
|
refreshSession.meltCoinPub,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!coin) {
|
||||||
|
console.error("can't melt coin, it does not exist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqUrl = new URL("refresh/melt", refreshSession.exchangeBaseUrl);
|
||||||
|
const meltReq = {
|
||||||
|
coin_pub: coin.coinPub,
|
||||||
|
confirm_sig: refreshSession.confirmSig,
|
||||||
|
denom_pub_hash: coin.denomPubHash,
|
||||||
|
denom_sig: coin.denomSig,
|
||||||
|
rc: refreshSession.hash,
|
||||||
|
value_with_fee: refreshSession.valueWithFee,
|
||||||
|
};
|
||||||
|
logger.trace("melt request:", meltReq);
|
||||||
|
const resp = await ws.http.postJson(reqUrl.href, meltReq);
|
||||||
|
|
||||||
|
logger.trace("melt response:", resp.responseJson);
|
||||||
|
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
console.error(resp.responseJson);
|
||||||
|
throw Error("refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const respJson = resp.responseJson;
|
||||||
|
|
||||||
|
const norevealIndex = respJson.noreveal_index;
|
||||||
|
|
||||||
|
if (typeof norevealIndex !== "number") {
|
||||||
|
throw Error("invalid response");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSession.norevealIndex = norevealIndex;
|
||||||
|
|
||||||
|
await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => {
|
||||||
|
if (rs.norevealIndex !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rs.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rs.norevealIndex = norevealIndex;
|
||||||
|
return rs;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.notifier.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshReveal(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
refreshSessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const refreshSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.refresh,
|
||||||
|
refreshSessionId,
|
||||||
|
);
|
||||||
|
if (!refreshSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const norevealIndex = refreshSession.norevealIndex;
|
||||||
|
if (norevealIndex === undefined) {
|
||||||
|
throw Error("can't reveal without melting first");
|
||||||
|
}
|
||||||
|
const privs = Array.from(refreshSession.transferPrivs);
|
||||||
|
privs.splice(norevealIndex, 1);
|
||||||
|
|
||||||
|
const planchets = refreshSession.planchetsForGammas[norevealIndex];
|
||||||
|
if (!planchets) {
|
||||||
|
throw Error("refresh index error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meltCoinRecord = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins,
|
||||||
|
refreshSession.meltCoinPub,
|
||||||
|
);
|
||||||
|
if (!meltCoinRecord) {
|
||||||
|
throw Error("inconsistent database");
|
||||||
|
}
|
||||||
|
|
||||||
|
const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv);
|
||||||
|
|
||||||
|
const linkSigs: string[] = [];
|
||||||
|
for (let i = 0; i < refreshSession.newDenoms.length; i++) {
|
||||||
|
const linkSig = await ws.cryptoApi.signCoinLink(
|
||||||
|
meltCoinRecord.coinPriv,
|
||||||
|
refreshSession.newDenomHashes[i],
|
||||||
|
refreshSession.meltCoinPub,
|
||||||
|
refreshSession.transferPubs[norevealIndex],
|
||||||
|
planchets[i].coinEv,
|
||||||
|
);
|
||||||
|
linkSigs.push(linkSig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
coin_evs: evs,
|
||||||
|
new_denoms_h: refreshSession.newDenomHashes,
|
||||||
|
rc: refreshSession.hash,
|
||||||
|
transfer_privs: privs,
|
||||||
|
transfer_pub: refreshSession.transferPubs[norevealIndex],
|
||||||
|
link_sigs: linkSigs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqUrl = new URL("refresh/reveal", refreshSession.exchangeBaseUrl);
|
||||||
|
logger.trace("reveal request:", req);
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await ws.http.postJson(reqUrl.href, req);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("got error during /refresh/reveal request");
|
||||||
|
console.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace("session:", refreshSession);
|
||||||
|
logger.trace("reveal response:", resp);
|
||||||
|
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
console.error("error: /refresh/reveal returned status " + resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const respJson = resp.responseJson;
|
||||||
|
|
||||||
|
if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) {
|
||||||
|
console.error("/refresh/reveal did not contain ev_sigs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchange = oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.exchanges,
|
||||||
|
refreshSession.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
if (!exchange) {
|
||||||
|
console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coins: CoinRecord[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < respJson.ev_sigs.length; i++) {
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
refreshSession.exchangeBaseUrl,
|
||||||
|
refreshSession.newDenoms[i],
|
||||||
|
]);
|
||||||
|
if (!denom) {
|
||||||
|
console.error("denom not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pc =
|
||||||
|
refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i];
|
||||||
|
const denomSig = await ws.cryptoApi.rsaUnblind(
|
||||||
|
respJson.ev_sigs[i].ev_sig,
|
||||||
|
pc.blindingKey,
|
||||||
|
denom.denomPub,
|
||||||
|
);
|
||||||
|
const coin: CoinRecord = {
|
||||||
|
blindingKey: pc.blindingKey,
|
||||||
|
coinPriv: pc.privateKey,
|
||||||
|
coinPub: pc.publicKey,
|
||||||
|
currentAmount: denom.value,
|
||||||
|
denomPub: denom.denomPub,
|
||||||
|
denomPubHash: denom.denomPubHash,
|
||||||
|
denomSig,
|
||||||
|
exchangeBaseUrl: refreshSession.exchangeBaseUrl,
|
||||||
|
reservePub: undefined,
|
||||||
|
status: CoinStatus.Fresh,
|
||||||
|
coinIndex: -1,
|
||||||
|
withdrawSessionId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
coins.push(coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSession.finished = true;
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.refresh],
|
||||||
|
async tx => {
|
||||||
|
const rs = await tx.get(Stores.refresh, refreshSessionId);
|
||||||
|
if (!rs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rs.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let coin of coins) {
|
||||||
|
await tx.put(Stores.coins, coin);
|
||||||
|
}
|
||||||
|
await tx.put(Stores.refresh, refreshSession);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.notifier.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processRefreshSession(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
refreshSessionId: string,
|
||||||
|
) {
|
||||||
|
const refreshSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.refresh,
|
||||||
|
refreshSessionId,
|
||||||
|
);
|
||||||
|
if (!refreshSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshSession.finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof refreshSession.norevealIndex !== "number") {
|
||||||
|
await refreshMelt(ws, refreshSession.refreshSessionId);
|
||||||
|
}
|
||||||
|
await refreshReveal(ws, refreshSession.refreshSessionId);
|
||||||
|
logger.trace("refresh finished");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
oldCoinPub: string,
|
||||||
|
force: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub);
|
||||||
|
if (!coin) {
|
||||||
|
console.warn("can't refresh, coin not in database");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (coin.status) {
|
||||||
|
case CoinStatus.Dirty:
|
||||||
|
break;
|
||||||
|
case CoinStatus.Dormant:
|
||||||
|
return;
|
||||||
|
case CoinStatus.Fresh:
|
||||||
|
if (!force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
|
||||||
|
if (!exchange) {
|
||||||
|
throw Error("db inconsistent: exchange of coin not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldDenom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
exchange.baseUrl,
|
||||||
|
coin.denomPub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!oldDenom) {
|
||||||
|
throw Error("db inconsistent: denomination for coin not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableDenoms: DenominationRecord[] = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
exchange.baseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh)
|
||||||
|
.amount;
|
||||||
|
|
||||||
|
const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
|
||||||
|
|
||||||
|
if (newCoinDenoms.length === 0) {
|
||||||
|
logger.trace(
|
||||||
|
`not refreshing, available amount ${amountToPretty(
|
||||||
|
availableAmount,
|
||||||
|
)} too small`,
|
||||||
|
);
|
||||||
|
await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => {
|
||||||
|
if (x.status != coin.status) {
|
||||||
|
// Concurrent modification?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.status = CoinStatus.Dormant;
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
ws.notifier.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession(
|
||||||
|
exchange.baseUrl,
|
||||||
|
3,
|
||||||
|
coin,
|
||||||
|
newCoinDenoms,
|
||||||
|
oldDenom.feeRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
function mutateCoin(c: CoinRecord): CoinRecord {
|
||||||
|
const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee);
|
||||||
|
if (r.saturated) {
|
||||||
|
// Something else must have written the coin value
|
||||||
|
throw TransactionAbort;
|
||||||
|
}
|
||||||
|
c.currentAmount = r.amount;
|
||||||
|
c.status = CoinStatus.Dormant;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store refresh session and subtract refreshed amount from
|
||||||
|
// coin in the same transaction.
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.refresh, Stores.coins],
|
||||||
|
async tx => {
|
||||||
|
await tx.put(Stores.refresh, refreshSession);
|
||||||
|
await tx.mutate(Stores.coins, coin.coinPub, mutateCoin);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logger.info(`created refresh session ${refreshSession.refreshSessionId}`);
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
await processRefreshSession(ws, refreshSession.refreshSessionId);
|
||||||
|
}
|
245
src/wallet-impl/refund.ts
Normal file
245
src/wallet-impl/refund.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
MerchantRefundResponse,
|
||||||
|
RefundRequest,
|
||||||
|
MerchantRefundPermission,
|
||||||
|
} from "../talerTypes";
|
||||||
|
import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes";
|
||||||
|
import { getTimestampNow } from "../walletTypes";
|
||||||
|
import {
|
||||||
|
oneShotMutate,
|
||||||
|
oneShotGet,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
oneShotIterIndex,
|
||||||
|
} from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { parseRefundUri } from "../util/taleruri";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { getTotalRefreshCost, refresh } from "./refresh";
|
||||||
|
|
||||||
|
const logger = new Logger("refund.ts");
|
||||||
|
|
||||||
|
export async function getFullRefundFees(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
refundPermissions: MerchantRefundPermission[],
|
||||||
|
): Promise<AmountJson> {
|
||||||
|
if (refundPermissions.length === 0) {
|
||||||
|
throw Error("no refunds given");
|
||||||
|
}
|
||||||
|
const coin0 = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins,
|
||||||
|
refundPermissions[0].coin_pub,
|
||||||
|
);
|
||||||
|
if (!coin0) {
|
||||||
|
throw Error("coin not found");
|
||||||
|
}
|
||||||
|
let feeAcc = Amounts.getZero(
|
||||||
|
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
|
||||||
|
);
|
||||||
|
|
||||||
|
const denoms = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
coin0.exchangeBaseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
for (const rp of refundPermissions) {
|
||||||
|
const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
|
||||||
|
if (!coin) {
|
||||||
|
throw Error("coin not found");
|
||||||
|
}
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
coin0.exchangeBaseUrl,
|
||||||
|
coin.denomPub,
|
||||||
|
]);
|
||||||
|
if (!denom) {
|
||||||
|
throw Error(`denom not found (${coin.denomPub})`);
|
||||||
|
}
|
||||||
|
// FIXME: this assumes that the refund already happened.
|
||||||
|
// When it hasn't, the refresh cost is inaccurate. To fix this,
|
||||||
|
// we need introduce a flag to tell if a coin was refunded or
|
||||||
|
// refreshed normally (and what about incremental refunds?)
|
||||||
|
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
|
||||||
|
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
|
||||||
|
const refreshCost = getTotalRefreshCost(
|
||||||
|
denoms,
|
||||||
|
denom,
|
||||||
|
Amounts.sub(refundAmount, refundFee).amount,
|
||||||
|
);
|
||||||
|
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
|
||||||
|
}
|
||||||
|
return feeAcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRefunds(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
contractTermsHash: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash);
|
||||||
|
if (!purchase) {
|
||||||
|
console.error(
|
||||||
|
"not submitting refunds, contract terms not found:",
|
||||||
|
contractTermsHash,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pendingKeys = Object.keys(purchase.refundsPending);
|
||||||
|
if (pendingKeys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const pk of pendingKeys) {
|
||||||
|
const perm = purchase.refundsPending[pk];
|
||||||
|
const req: RefundRequest = {
|
||||||
|
coin_pub: perm.coin_pub,
|
||||||
|
h_contract_terms: purchase.contractTermsHash,
|
||||||
|
merchant_pub: purchase.contractTerms.merchant_pub,
|
||||||
|
merchant_sig: perm.merchant_sig,
|
||||||
|
refund_amount: perm.refund_amount,
|
||||||
|
refund_fee: perm.refund_fee,
|
||||||
|
rtransaction_id: perm.rtransaction_id,
|
||||||
|
};
|
||||||
|
console.log("sending refund permission", perm);
|
||||||
|
// FIXME: not correct once we support multiple exchanges per payment
|
||||||
|
const exchangeUrl = purchase.payReq.coins[0].exchange_url;
|
||||||
|
const reqUrl = new URL("refund", exchangeUrl);
|
||||||
|
const resp = await ws.http.postJson(reqUrl.href, req);
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
console.error("refund failed", resp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transactionally mark successful refunds as done
|
||||||
|
const transformPurchase = (
|
||||||
|
t: PurchaseRecord | undefined,
|
||||||
|
): PurchaseRecord | undefined => {
|
||||||
|
if (!t) {
|
||||||
|
console.warn("purchase not found, not updating refund");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.refundsPending[pk]) {
|
||||||
|
t.refundsDone[pk] = t.refundsPending[pk];
|
||||||
|
delete t.refundsPending[pk];
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
const transformCoin = (
|
||||||
|
c: CoinRecord | undefined,
|
||||||
|
): CoinRecord | undefined => {
|
||||||
|
if (!c) {
|
||||||
|
console.warn("coin not found, can't apply refund");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
|
||||||
|
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
|
||||||
|
c.status = CoinStatus.Dirty;
|
||||||
|
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
|
||||||
|
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
|
||||||
|
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.purchases, Stores.coins],
|
||||||
|
async tx => {
|
||||||
|
await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase);
|
||||||
|
await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
refresh(ws, perm.coin_pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.badge.showNotification();
|
||||||
|
ws.notifier.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptRefundResponse(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
refundResponse: MerchantRefundResponse,
|
||||||
|
): Promise<string> {
|
||||||
|
const refundPermissions = refundResponse.refund_permissions;
|
||||||
|
|
||||||
|
if (!refundPermissions.length) {
|
||||||
|
console.warn("got empty refund list");
|
||||||
|
throw Error("empty refund");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add refund to purchase if not already added.
|
||||||
|
*/
|
||||||
|
function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined {
|
||||||
|
if (!t) {
|
||||||
|
console.error("purchase not found, not adding refunds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
t.timestamp_refund = getTimestampNow();
|
||||||
|
|
||||||
|
for (const perm of refundPermissions) {
|
||||||
|
if (
|
||||||
|
!t.refundsPending[perm.merchant_sig] &&
|
||||||
|
!t.refundsDone[perm.merchant_sig]
|
||||||
|
) {
|
||||||
|
t.refundsPending[perm.merchant_sig] = perm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hc = refundResponse.h_contract_terms;
|
||||||
|
|
||||||
|
// Add the refund permissions to the purchase within a DB transaction
|
||||||
|
await oneShotMutate(ws.db, Stores.purchases, hc, f);
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
await submitRefunds(ws, hc);
|
||||||
|
|
||||||
|
return hc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a refund, return the contract hash for the contract
|
||||||
|
* that was involved in the refund.
|
||||||
|
*/
|
||||||
|
export async function applyRefund(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerRefundUri: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const parseResult = parseRefundUri(talerRefundUri);
|
||||||
|
|
||||||
|
if (!parseResult) {
|
||||||
|
throw Error("invalid refund URI");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundUrl = parseResult.refundUrl;
|
||||||
|
|
||||||
|
logger.trace("processing refund");
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await ws.http.get(refundUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error downloading refund permission", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
|
||||||
|
return acceptRefundResponse(ws, refundResponse);
|
||||||
|
}
|
567
src/wallet-impl/reserves.ts
Normal file
567
src/wallet-impl/reserves.ts
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateReserveRequest,
|
||||||
|
CreateReserveResponse,
|
||||||
|
getTimestampNow,
|
||||||
|
ConfirmReserveRequest,
|
||||||
|
OperationError,
|
||||||
|
} from "../walletTypes";
|
||||||
|
import { canonicalizeBaseUrl } from "../util/helpers";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import {
|
||||||
|
ReserveRecordStatus,
|
||||||
|
ReserveRecord,
|
||||||
|
CurrencyRecord,
|
||||||
|
Stores,
|
||||||
|
WithdrawalSessionRecord,
|
||||||
|
} from "../dbTypes";
|
||||||
|
import {
|
||||||
|
oneShotMutate,
|
||||||
|
oneShotPut,
|
||||||
|
oneShotGet,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
TransactionAbort,
|
||||||
|
} from "../util/query";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
|
||||||
|
import { WithdrawOperationStatusResponse, ReserveStatus } from "../talerTypes";
|
||||||
|
import { assertUnreachable } from "../util/assertUnreachable";
|
||||||
|
import { OperationFailedAndReportedError } from "../wallet";
|
||||||
|
import { encodeCrock } from "../crypto/talerCrypto";
|
||||||
|
import { randomBytes } from "../crypto/primitives/nacl-fast";
|
||||||
|
import {
|
||||||
|
getVerifiedWithdrawDenomList,
|
||||||
|
processWithdrawSession,
|
||||||
|
} from "./withdraw";
|
||||||
|
|
||||||
|
const logger = new Logger("reserves.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reserve, but do not flag it as confirmed yet.
|
||||||
|
*
|
||||||
|
* Adds the corresponding exchange as a trusted exchange if it is neither
|
||||||
|
* audited nor trusted already.
|
||||||
|
*/
|
||||||
|
export async function createReserve(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
req: CreateReserveRequest,
|
||||||
|
): Promise<CreateReserveResponse> {
|
||||||
|
const keypair = await ws.cryptoApi.createEddsaKeypair();
|
||||||
|
const now = getTimestampNow();
|
||||||
|
const canonExchange = canonicalizeBaseUrl(req.exchange);
|
||||||
|
|
||||||
|
let reserveStatus;
|
||||||
|
if (req.bankWithdrawStatusUrl) {
|
||||||
|
reserveStatus = ReserveRecordStatus.REGISTERING_BANK;
|
||||||
|
} else {
|
||||||
|
reserveStatus = ReserveRecordStatus.UNCONFIRMED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = req.amount.currency;
|
||||||
|
|
||||||
|
const reserveRecord: ReserveRecord = {
|
||||||
|
created: now,
|
||||||
|
withdrawAllocatedAmount: Amounts.getZero(currency),
|
||||||
|
withdrawCompletedAmount: Amounts.getZero(currency),
|
||||||
|
withdrawRemainingAmount: Amounts.getZero(currency),
|
||||||
|
exchangeBaseUrl: canonExchange,
|
||||||
|
hasPayback: false,
|
||||||
|
initiallyRequestedAmount: req.amount,
|
||||||
|
reservePriv: keypair.priv,
|
||||||
|
reservePub: keypair.pub,
|
||||||
|
senderWire: req.senderWire,
|
||||||
|
timestampConfirmed: undefined,
|
||||||
|
timestampReserveInfoPosted: undefined,
|
||||||
|
bankWithdrawStatusUrl: req.bankWithdrawStatusUrl,
|
||||||
|
exchangeWire: req.exchangeWire,
|
||||||
|
reserveStatus,
|
||||||
|
lastStatusQuery: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const senderWire = req.senderWire;
|
||||||
|
if (senderWire) {
|
||||||
|
const rec = {
|
||||||
|
paytoUri: senderWire,
|
||||||
|
};
|
||||||
|
await oneShotPut(ws.db, Stores.senderWires, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
|
||||||
|
const exchangeDetails = exchangeInfo.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
throw Error("exchange not updated");
|
||||||
|
}
|
||||||
|
const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo);
|
||||||
|
let currencyRecord = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.currencies,
|
||||||
|
exchangeDetails.currency,
|
||||||
|
);
|
||||||
|
if (!currencyRecord) {
|
||||||
|
currencyRecord = {
|
||||||
|
auditors: [],
|
||||||
|
exchanges: [],
|
||||||
|
fractionalDigits: 2,
|
||||||
|
name: exchangeDetails.currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAudited && !isTrusted) {
|
||||||
|
currencyRecord.exchanges.push({
|
||||||
|
baseUrl: req.exchange,
|
||||||
|
exchangePub: exchangeDetails.masterPublicKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cr: CurrencyRecord = currencyRecord;
|
||||||
|
|
||||||
|
const resp = await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.currencies, Stores.reserves, Stores.bankWithdrawUris],
|
||||||
|
async tx => {
|
||||||
|
// Check if we have already created a reserve for that bankWithdrawStatusUrl
|
||||||
|
if (reserveRecord.bankWithdrawStatusUrl) {
|
||||||
|
const bwi = await tx.get(
|
||||||
|
Stores.bankWithdrawUris,
|
||||||
|
reserveRecord.bankWithdrawStatusUrl,
|
||||||
|
);
|
||||||
|
if (bwi) {
|
||||||
|
const otherReserve = await tx.get(Stores.reserves, bwi.reservePub);
|
||||||
|
if (otherReserve) {
|
||||||
|
logger.trace(
|
||||||
|
"returning existing reserve for bankWithdrawStatusUri",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
exchange: otherReserve.exchangeBaseUrl,
|
||||||
|
reservePub: otherReserve.reservePub,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.put(Stores.bankWithdrawUris, {
|
||||||
|
reservePub: reserveRecord.reservePub,
|
||||||
|
talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.put(Stores.currencies, cr);
|
||||||
|
await tx.put(Stores.reserves, reserveRecord);
|
||||||
|
const r: CreateReserveResponse = {
|
||||||
|
exchange: canonExchange,
|
||||||
|
reservePub: keypair.pub,
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Asynchronously process the reserve, but return
|
||||||
|
// to the caller already.
|
||||||
|
processReserve(ws, resp.reservePub).catch(e => {
|
||||||
|
console.error("Processing reserve failed:", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First fetch information requred to withdraw from the reserve,
|
||||||
|
* then deplete the reserve, withdrawing coins until it is empty.
|
||||||
|
*
|
||||||
|
* The returned promise resolves once the reserve is set to the
|
||||||
|
* state DORMANT.
|
||||||
|
*/
|
||||||
|
export async function processReserve(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const p = ws.memoProcessReserve.find(reservePub);
|
||||||
|
if (p) {
|
||||||
|
return p;
|
||||||
|
} else {
|
||||||
|
return ws.memoProcessReserve.put(
|
||||||
|
reservePub,
|
||||||
|
processReserveImpl(ws, reservePub),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerReserveWithBank(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
switch (reserve?.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bankStatusUrl = reserve.bankWithdrawStatusUrl;
|
||||||
|
if (!bankStatusUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("making selection");
|
||||||
|
if (reserve.timestampReserveInfoPosted) {
|
||||||
|
throw Error("bank claims that reserve info selection is not done");
|
||||||
|
}
|
||||||
|
const bankResp = await ws.http.postJson(bankStatusUrl, {
|
||||||
|
reserve_pub: reservePub,
|
||||||
|
selected_exchange: reserve.exchangeWire,
|
||||||
|
});
|
||||||
|
console.log("got response", bankResp);
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, reservePub, r => {
|
||||||
|
switch (r.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.timestampReserveInfoPosted = getTimestampNow();
|
||||||
|
r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return processReserveBankStatus(ws, reservePub);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processReserveBankStatus(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
switch (reserve?.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bankStatusUrl = reserve.bankWithdrawStatusUrl;
|
||||||
|
if (!bankStatusUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: WithdrawOperationStatusResponse;
|
||||||
|
try {
|
||||||
|
const statusResp = await ws.http.get(bankStatusUrl);
|
||||||
|
status = WithdrawOperationStatusResponse.checked(statusResp.responseJson);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.selection_done) {
|
||||||
|
if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) {
|
||||||
|
await registerReserveWithBank(ws, reservePub);
|
||||||
|
return await processReserveBankStatus(ws, reservePub);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await registerReserveWithBank(ws, reservePub);
|
||||||
|
return await processReserveBankStatus(ws, reservePub);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.transfer_done) {
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, reservePub, r => {
|
||||||
|
switch (r.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = getTimestampNow();
|
||||||
|
r.timestampConfirmed = now;
|
||||||
|
r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
await processReserveImpl(ws, reservePub);
|
||||||
|
} else {
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, reservePub, r => {
|
||||||
|
switch (r.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.bankWithdrawConfirmUrl = status.confirm_transfer_url;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setReserveError(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
err: OperationError,
|
||||||
|
): Promise<void> {
|
||||||
|
const mut = (reserve: ReserveRecord) => {
|
||||||
|
reserve.lastError = err;
|
||||||
|
return reserve;
|
||||||
|
};
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, reservePub, mut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the information about a reserve that is stored in the wallet
|
||||||
|
* by quering the reserve's exchange.
|
||||||
|
*/
|
||||||
|
async function updateReserve(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
if (!reserve) {
|
||||||
|
throw Error("reserve not in db");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reserve.timestampConfirmed === undefined) {
|
||||||
|
throw Error("reserve not confirmed yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqUrl = new URL("reserve/status", reserve.exchangeBaseUrl);
|
||||||
|
reqUrl.searchParams.set("reserve_pub", reservePub);
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await ws.http.get(reqUrl.href);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.status === 404) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const m = e.message;
|
||||||
|
setReserveError(ws, reservePub, {
|
||||||
|
type: "network",
|
||||||
|
details: {},
|
||||||
|
message: m,
|
||||||
|
});
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reserveInfo = ReserveStatus.checked(resp.responseJson);
|
||||||
|
const balance = Amounts.parseOrThrow(reserveInfo.balance);
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, reserve.reservePub, r => {
|
||||||
|
if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: check / compare history!
|
||||||
|
if (!r.lastStatusQuery) {
|
||||||
|
// FIXME: check if this matches initial expectations
|
||||||
|
r.withdrawRemainingAmount = balance;
|
||||||
|
} else {
|
||||||
|
const expectedBalance = Amounts.sub(
|
||||||
|
r.withdrawAllocatedAmount,
|
||||||
|
r.withdrawCompletedAmount,
|
||||||
|
);
|
||||||
|
const cmp = Amounts.cmp(balance, expectedBalance.amount);
|
||||||
|
if (cmp == 0) {
|
||||||
|
// Nothing changed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmp > 0) {
|
||||||
|
const extra = Amounts.sub(balance, expectedBalance.amount).amount;
|
||||||
|
r.withdrawRemainingAmount = Amounts.add(
|
||||||
|
r.withdrawRemainingAmount,
|
||||||
|
extra,
|
||||||
|
).amount;
|
||||||
|
} else {
|
||||||
|
// We're missing some money.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.lastStatusQuery = getTimestampNow();
|
||||||
|
r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
ws.notifier.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processReserveImpl(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
if (!reserve) {
|
||||||
|
console.log("not processing reserve: reserve does not exist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.trace(
|
||||||
|
`Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
|
||||||
|
);
|
||||||
|
switch (reserve.reserveStatus) {
|
||||||
|
case ReserveRecordStatus.UNCONFIRMED:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
|
case ReserveRecordStatus.REGISTERING_BANK:
|
||||||
|
await processReserveBankStatus(ws, reservePub);
|
||||||
|
return processReserveImpl(ws, reservePub);
|
||||||
|
case ReserveRecordStatus.QUERYING_STATUS:
|
||||||
|
await updateReserve(ws, reservePub);
|
||||||
|
return processReserveImpl(ws, reservePub);
|
||||||
|
case ReserveRecordStatus.WITHDRAWING:
|
||||||
|
await depleteReserve(ws, reservePub);
|
||||||
|
break;
|
||||||
|
case ReserveRecordStatus.DORMANT:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
||||||
|
await processReserveBankStatus(ws, reservePub);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("unknown reserve record status:", reserve.reserveStatus);
|
||||||
|
assertUnreachable(reserve.reserveStatus);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmReserve(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
req: ConfirmReserveRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = getTimestampNow();
|
||||||
|
await oneShotMutate(ws.db, Stores.reserves, req.reservePub, reserve => {
|
||||||
|
if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reserve.timestampConfirmed = now;
|
||||||
|
reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||||
|
return reserve;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
processReserve(ws, req.reservePub).catch(e => {
|
||||||
|
console.log("processing reserve failed:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw coins from a reserve until it is empty.
|
||||||
|
*
|
||||||
|
* When finished, marks the reserve as depleted by setting
|
||||||
|
* the depleted timestamp.
|
||||||
|
*/
|
||||||
|
async function depleteReserve(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
reservePub: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub);
|
||||||
|
if (!reserve) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.trace(`depleting reserve ${reservePub}`);
|
||||||
|
|
||||||
|
const withdrawAmount = reserve.withdrawRemainingAmount;
|
||||||
|
|
||||||
|
logger.trace(`getting denom list`);
|
||||||
|
|
||||||
|
const denomsForWithdraw = await getVerifiedWithdrawDenomList(
|
||||||
|
ws,
|
||||||
|
reserve.exchangeBaseUrl,
|
||||||
|
withdrawAmount,
|
||||||
|
);
|
||||||
|
logger.trace(`got denom list`);
|
||||||
|
if (denomsForWithdraw.length === 0) {
|
||||||
|
const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`;
|
||||||
|
await setReserveError(ws, reserve.reservePub, {
|
||||||
|
type: "internal",
|
||||||
|
message: m,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
console.log(m);
|
||||||
|
throw new OperationFailedAndReportedError(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace("selected denominations");
|
||||||
|
|
||||||
|
const withdrawalSessionId = encodeCrock(randomBytes(32));
|
||||||
|
|
||||||
|
const withdrawalRecord: WithdrawalSessionRecord = {
|
||||||
|
withdrawSessionId: withdrawalSessionId,
|
||||||
|
exchangeBaseUrl: reserve.exchangeBaseUrl,
|
||||||
|
source: {
|
||||||
|
type: "reserve",
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
},
|
||||||
|
withdrawalAmount: Amounts.toString(withdrawAmount),
|
||||||
|
startTimestamp: getTimestampNow(),
|
||||||
|
denoms: denomsForWithdraw.map(x => x.denomPub),
|
||||||
|
withdrawn: denomsForWithdraw.map(x => false),
|
||||||
|
planchets: denomsForWithdraw.map(x => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value))
|
||||||
|
.amount;
|
||||||
|
const totalCoinWithdrawFee = Amounts.sum(
|
||||||
|
denomsForWithdraw.map(x => x.feeWithdraw),
|
||||||
|
).amount;
|
||||||
|
const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee)
|
||||||
|
.amount;
|
||||||
|
|
||||||
|
function mutateReserve(r: ReserveRecord): ReserveRecord {
|
||||||
|
const remaining = Amounts.sub(
|
||||||
|
r.withdrawRemainingAmount,
|
||||||
|
totalWithdrawAmount,
|
||||||
|
);
|
||||||
|
if (remaining.saturated) {
|
||||||
|
console.error("can't create planchets, saturated");
|
||||||
|
throw TransactionAbort;
|
||||||
|
}
|
||||||
|
const allocated = Amounts.add(
|
||||||
|
r.withdrawAllocatedAmount,
|
||||||
|
totalWithdrawAmount,
|
||||||
|
);
|
||||||
|
if (allocated.saturated) {
|
||||||
|
console.error("can't create planchets, saturated");
|
||||||
|
throw TransactionAbort;
|
||||||
|
}
|
||||||
|
r.withdrawRemainingAmount = remaining.amount;
|
||||||
|
r.withdrawAllocatedAmount = allocated.amount;
|
||||||
|
r.reserveStatus = ReserveRecordStatus.DORMANT;
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.withdrawalSession, Stores.reserves],
|
||||||
|
async tx => {
|
||||||
|
const myReserve = await tx.get(Stores.reserves, reservePub);
|
||||||
|
if (!myReserve) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve);
|
||||||
|
await tx.put(Stores.withdrawalSession, withdrawalRecord);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log("processing new withdraw session");
|
||||||
|
await processWithdrawSession(ws, withdrawalSessionId);
|
||||||
|
} else {
|
||||||
|
console.trace("withdraw session already existed");
|
||||||
|
}
|
||||||
|
}
|
274
src/wallet-impl/return.ts
Normal file
274
src/wallet-impl/return.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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 {
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryEvent,
|
||||||
|
WalletBalance,
|
||||||
|
WalletBalanceEntry,
|
||||||
|
ReturnCoinsRequest,
|
||||||
|
CoinWithDenom,
|
||||||
|
} from "../walletTypes";
|
||||||
|
import { oneShotIter, runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../dbTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import { canonicalJson } from "../util/helpers";
|
||||||
|
import { ContractTerms } from "../talerTypes";
|
||||||
|
import { selectPayCoins } from "./pay";
|
||||||
|
|
||||||
|
const logger = new Logger("return.ts");
|
||||||
|
|
||||||
|
async function getCoinsForReturn(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
amount: AmountJson,
|
||||||
|
): Promise<CoinWithDenom[] | undefined> {
|
||||||
|
const exchange = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.exchanges,
|
||||||
|
exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
if (!exchange) {
|
||||||
|
throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coins: CoinRecord[] = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins.exchangeBaseUrlIndex,
|
||||||
|
exchange.baseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
if (!coins || !coins.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const denoms = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
exchange.baseUrl,
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
// Denomination of the first coin, we assume that all other
|
||||||
|
// coins have the same currency
|
||||||
|
const firstDenom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
exchange.baseUrl,
|
||||||
|
coins[0].denomPub,
|
||||||
|
]);
|
||||||
|
if (!firstDenom) {
|
||||||
|
throw Error("db inconsistent");
|
||||||
|
}
|
||||||
|
const currency = firstDenom.value.currency;
|
||||||
|
|
||||||
|
const cds: CoinWithDenom[] = [];
|
||||||
|
for (const coin of coins) {
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
exchange.baseUrl,
|
||||||
|
coin.denomPub,
|
||||||
|
]);
|
||||||
|
if (!denom) {
|
||||||
|
throw Error("db inconsistent");
|
||||||
|
}
|
||||||
|
if (denom.value.currency !== currency) {
|
||||||
|
console.warn(
|
||||||
|
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coin.suspended) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coin.status !== CoinStatus.Fresh) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cds.push({ coin, denom });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = selectPayCoins(denoms, cds, amount, amount);
|
||||||
|
if (res) {
|
||||||
|
return res.cds;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger paying coins back into the user's account.
|
||||||
|
*/
|
||||||
|
export async function returnCoins(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
req: ReturnCoinsRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.trace("got returnCoins request", req);
|
||||||
|
const wireType = (req.senderWire as any).type;
|
||||||
|
logger.trace("wireType", wireType);
|
||||||
|
if (!wireType || typeof wireType !== "string") {
|
||||||
|
console.error(`wire type must be a non-empty string, not ${wireType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stampSecNow = Math.floor(new Date().getTime() / 1000);
|
||||||
|
const exchange = await oneShotGet(ws.db, Stores.exchanges, req.exchange);
|
||||||
|
if (!exchange) {
|
||||||
|
console.error(`Exchange ${req.exchange} not known to the wallet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exchangeDetails = exchange.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
throw Error("exchange information needs to be updated first.");
|
||||||
|
}
|
||||||
|
logger.trace("selecting coins for return:", req);
|
||||||
|
const cds = await getCoinsForReturn(ws, req.exchange, req.amount);
|
||||||
|
logger.trace(cds);
|
||||||
|
|
||||||
|
if (!cds) {
|
||||||
|
throw Error("coin return impossible, can't select coins");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
|
||||||
|
|
||||||
|
const wireHash = await ws.cryptoApi.hashString(
|
||||||
|
canonicalJson(req.senderWire),
|
||||||
|
);
|
||||||
|
|
||||||
|
const contractTerms: ContractTerms = {
|
||||||
|
H_wire: wireHash,
|
||||||
|
amount: Amounts.toString(req.amount),
|
||||||
|
auditors: [],
|
||||||
|
exchanges: [
|
||||||
|
{ master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl },
|
||||||
|
],
|
||||||
|
extra: {},
|
||||||
|
fulfillment_url: "",
|
||||||
|
locations: [],
|
||||||
|
max_fee: Amounts.toString(req.amount),
|
||||||
|
merchant: {},
|
||||||
|
merchant_pub: pub,
|
||||||
|
order_id: "none",
|
||||||
|
pay_deadline: `/Date(${stampSecNow + 30 * 5})/`,
|
||||||
|
wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`,
|
||||||
|
merchant_base_url: "taler://return-to-account",
|
||||||
|
products: [],
|
||||||
|
refund_deadline: `/Date(${stampSecNow + 60 * 5})/`,
|
||||||
|
timestamp: `/Date(${stampSecNow})/`,
|
||||||
|
wire_method: wireType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractTermsHash = await ws.cryptoApi.hashString(
|
||||||
|
canonicalJson(contractTerms),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payCoinInfo = await ws.cryptoApi.signDeposit(
|
||||||
|
contractTerms,
|
||||||
|
cds,
|
||||||
|
Amounts.parseOrThrow(contractTerms.amount),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.trace("pci", payCoinInfo);
|
||||||
|
|
||||||
|
const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s }));
|
||||||
|
|
||||||
|
const coinsReturnRecord: CoinsReturnRecord = {
|
||||||
|
coins,
|
||||||
|
contractTerms,
|
||||||
|
contractTermsHash,
|
||||||
|
exchange: exchange.baseUrl,
|
||||||
|
merchantPriv: priv,
|
||||||
|
wire: req.senderWire,
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coinsReturns, Stores.coins],
|
||||||
|
async tx => {
|
||||||
|
await tx.put(Stores.coinsReturns, coinsReturnRecord);
|
||||||
|
for (let c of payCoinInfo.updatedCoins) {
|
||||||
|
await tx.put(Stores.coins, c);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.badge.showNotification();
|
||||||
|
ws.notifier.notify();
|
||||||
|
|
||||||
|
depositReturnedCoins(ws, coinsReturnRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function depositReturnedCoins(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
coinsReturnRecord: CoinsReturnRecord,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const c of coinsReturnRecord.coins) {
|
||||||
|
if (c.depositedSig) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const req = {
|
||||||
|
H_wire: coinsReturnRecord.contractTerms.H_wire,
|
||||||
|
coin_pub: c.coinPaySig.coin_pub,
|
||||||
|
coin_sig: c.coinPaySig.coin_sig,
|
||||||
|
contribution: c.coinPaySig.contribution,
|
||||||
|
denom_pub: c.coinPaySig.denom_pub,
|
||||||
|
h_contract_terms: coinsReturnRecord.contractTermsHash,
|
||||||
|
merchant_pub: coinsReturnRecord.contractTerms.merchant_pub,
|
||||||
|
pay_deadline: coinsReturnRecord.contractTerms.pay_deadline,
|
||||||
|
refund_deadline: coinsReturnRecord.contractTerms.refund_deadline,
|
||||||
|
timestamp: coinsReturnRecord.contractTerms.timestamp,
|
||||||
|
ub_sig: c.coinPaySig.ub_sig,
|
||||||
|
wire: coinsReturnRecord.wire,
|
||||||
|
wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline,
|
||||||
|
};
|
||||||
|
logger.trace("req", req);
|
||||||
|
const reqUrl = new URL("deposit", coinsReturnRecord.exchange);
|
||||||
|
const resp = await ws.http.postJson(reqUrl.href, req);
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
console.error("deposit failed due to status code", resp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const respJson = resp.responseJson;
|
||||||
|
if (respJson.status !== "DEPOSIT_OK") {
|
||||||
|
console.error("deposit failed", resp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!respJson.sig) {
|
||||||
|
console.error("invalid 'sig' field", resp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: verify signature
|
||||||
|
|
||||||
|
// For every successful deposit, we replace the old record with an updated one
|
||||||
|
const currentCrr = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.coinsReturns,
|
||||||
|
coinsReturnRecord.contractTermsHash,
|
||||||
|
);
|
||||||
|
if (!currentCrr) {
|
||||||
|
console.error("database inconsistent");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const nc of currentCrr.coins) {
|
||||||
|
if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) {
|
||||||
|
nc.depositedSig = respJson.sig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await oneShotPut(ws.db, Stores.coinsReturns, currentCrr);
|
||||||
|
ws.notifier.notify();
|
||||||
|
}
|
||||||
|
}
|
32
src/wallet-impl/state.ts
Normal file
32
src/wallet-impl/state.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpRequestLibrary } from "../util/http";
|
||||||
|
import { Badge, Notifier, NextUrlResult } from "../walletTypes";
|
||||||
|
import { SpeculativePayData } from "./pay";
|
||||||
|
import { CryptoApi } from "../crypto/cryptoApi";
|
||||||
|
import { AsyncOpMemo } from "../util/asyncMemo";
|
||||||
|
|
||||||
|
export interface InternalWalletState {
|
||||||
|
db: IDBDatabase;
|
||||||
|
http: HttpRequestLibrary;
|
||||||
|
badge: Badge;
|
||||||
|
notifier: Notifier;
|
||||||
|
cryptoApi: CryptoApi;
|
||||||
|
speculativePayData: SpeculativePayData | undefined;
|
||||||
|
cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult };
|
||||||
|
memoProcessReserve: AsyncOpMemo<void>;
|
||||||
|
}
|
246
src/wallet-impl/tip.ts
Normal file
246
src/wallet-impl/tip.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { parseTipUri } from "../util/taleruri";
|
||||||
|
import { TipStatus, getTimestampNow } from "../walletTypes";
|
||||||
|
import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import { Stores, PlanchetRecord, WithdrawalSessionRecord } from "../dbTypes";
|
||||||
|
import { getWithdrawDetailsForAmount, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw";
|
||||||
|
import { getTalerStampSec } from "../util/helpers";
|
||||||
|
import { updateExchangeFromUrl } from "./exchanges";
|
||||||
|
import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
|
||||||
|
|
||||||
|
|
||||||
|
export async function getTipStatus(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerTipUri: string): Promise<TipStatus> {
|
||||||
|
const res = parseTipUri(talerTipUri);
|
||||||
|
if (!res) {
|
||||||
|
throw Error("invalid taler://tip URI");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl);
|
||||||
|
tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
|
||||||
|
console.log("checking tip status from", tipStatusUrl.href);
|
||||||
|
const merchantResp = await ws.http.get(tipStatusUrl.href);
|
||||||
|
console.log("resp:", merchantResp.responseJson);
|
||||||
|
const tipPickupStatus = TipPickupGetResponse.checked(
|
||||||
|
merchantResp.responseJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("status", tipPickupStatus);
|
||||||
|
|
||||||
|
let amount = Amounts.parseOrThrow(tipPickupStatus.amount);
|
||||||
|
|
||||||
|
let tipRecord = await oneShotGet(ws.db, Stores.tips, [
|
||||||
|
res.merchantTipId,
|
||||||
|
res.merchantOrigin,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!tipRecord) {
|
||||||
|
const withdrawDetails = await getWithdrawDetailsForAmount(
|
||||||
|
ws,
|
||||||
|
tipPickupStatus.exchange_url,
|
||||||
|
amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tipId = encodeCrock(getRandomBytes(32));
|
||||||
|
|
||||||
|
tipRecord = {
|
||||||
|
tipId,
|
||||||
|
accepted: false,
|
||||||
|
amount,
|
||||||
|
deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!,
|
||||||
|
exchangeUrl: tipPickupStatus.exchange_url,
|
||||||
|
merchantBaseUrl: res.merchantBaseUrl,
|
||||||
|
nextUrl: undefined,
|
||||||
|
pickedUp: false,
|
||||||
|
planchets: undefined,
|
||||||
|
response: undefined,
|
||||||
|
timestamp: getTimestampNow(),
|
||||||
|
merchantTipId: res.merchantTipId,
|
||||||
|
totalFees: Amounts.add(
|
||||||
|
withdrawDetails.overhead,
|
||||||
|
withdrawDetails.withdrawFee,
|
||||||
|
).amount,
|
||||||
|
};
|
||||||
|
await oneShotPut(ws.db, Stores.tips, tipRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tipStatus: TipStatus = {
|
||||||
|
accepted: !!tipRecord && tipRecord.accepted,
|
||||||
|
amount: Amounts.parseOrThrow(tipPickupStatus.amount),
|
||||||
|
amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
|
||||||
|
exchangeUrl: tipPickupStatus.exchange_url,
|
||||||
|
nextUrl: tipPickupStatus.extra.next_url,
|
||||||
|
merchantOrigin: res.merchantOrigin,
|
||||||
|
merchantTipId: res.merchantTipId,
|
||||||
|
expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!,
|
||||||
|
timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!,
|
||||||
|
totalFees: tipRecord.totalFees,
|
||||||
|
tipId: tipRecord.tipId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return tipStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processTip(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
tipId: string,
|
||||||
|
) {
|
||||||
|
let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId);
|
||||||
|
if (!tipRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipRecord.pickedUp) {
|
||||||
|
console.log("tip already picked up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tipRecord.planchets) {
|
||||||
|
await updateExchangeFromUrl(ws, tipRecord.exchangeUrl);
|
||||||
|
const denomsForWithdraw = await getVerifiedWithdrawDenomList(
|
||||||
|
ws,
|
||||||
|
tipRecord.exchangeUrl,
|
||||||
|
tipRecord.amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planchets = await Promise.all(
|
||||||
|
denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await oneShotMutate(ws.db, Stores.tips, tipId, r => {
|
||||||
|
if (!r.planchets) {
|
||||||
|
r.planchets = planchets;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tipRecord = await oneShotGet(ws.db, Stores.tips, tipId);
|
||||||
|
if (!tipRecord) {
|
||||||
|
throw Error("tip not in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tipRecord.planchets) {
|
||||||
|
throw Error("invariant violated");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("got planchets for tip!");
|
||||||
|
|
||||||
|
// Planchets in the form that the merchant expects
|
||||||
|
const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({
|
||||||
|
coin_ev: p.coinEv,
|
||||||
|
denom_pub_hash: p.denomPubHash,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let merchantResp;
|
||||||
|
|
||||||
|
const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId };
|
||||||
|
merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
|
||||||
|
console.log("got merchant resp:", merchantResp);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("tipping failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = TipResponse.checked(merchantResp.responseJson);
|
||||||
|
|
||||||
|
if (response.reserve_sigs.length !== tipRecord.planchets.length) {
|
||||||
|
throw Error("number of tip responses does not match requested planchets");
|
||||||
|
}
|
||||||
|
|
||||||
|
const planchets: PlanchetRecord[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < tipRecord.planchets.length; i++) {
|
||||||
|
const tipPlanchet = tipRecord.planchets[i];
|
||||||
|
const planchet: PlanchetRecord = {
|
||||||
|
blindingKey: tipPlanchet.blindingKey,
|
||||||
|
coinEv: tipPlanchet.coinEv,
|
||||||
|
coinPriv: tipPlanchet.coinPriv,
|
||||||
|
coinPub: tipPlanchet.coinPub,
|
||||||
|
coinValue: tipPlanchet.coinValue,
|
||||||
|
denomPub: tipPlanchet.denomPub,
|
||||||
|
denomPubHash: tipPlanchet.denomPubHash,
|
||||||
|
reservePub: response.reserve_pub,
|
||||||
|
withdrawSig: response.reserve_sigs[i].reserve_sig,
|
||||||
|
isFromTip: true,
|
||||||
|
};
|
||||||
|
planchets.push(planchet);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawalSessionId = encodeCrock(getRandomBytes(32));
|
||||||
|
|
||||||
|
const withdrawalSession: WithdrawalSessionRecord = {
|
||||||
|
denoms: planchets.map((x) => x.denomPub),
|
||||||
|
exchangeBaseUrl: tipRecord.exchangeUrl,
|
||||||
|
planchets: planchets,
|
||||||
|
source: {
|
||||||
|
type: "tip",
|
||||||
|
tipId: tipRecord.tipId,
|
||||||
|
},
|
||||||
|
startTimestamp: getTimestampNow(),
|
||||||
|
withdrawSessionId: withdrawalSessionId,
|
||||||
|
withdrawalAmount: Amounts.toString(tipRecord.amount),
|
||||||
|
withdrawn: planchets.map((x) => false),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], async (tx) => {
|
||||||
|
const tr = await tx.get(Stores.tips, tipId);
|
||||||
|
if (!tr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tr.pickedUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tr.pickedUp = true;
|
||||||
|
|
||||||
|
await tx.put(Stores.tips, tr);
|
||||||
|
await tx.put(Stores.withdrawalSession, withdrawalSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
await processWithdrawSession(ws, withdrawalSessionId);
|
||||||
|
|
||||||
|
ws.notifier.notify();
|
||||||
|
ws.badge.showNotification();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptTip(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
tipId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId);
|
||||||
|
if (!tipRecord) {
|
||||||
|
console.log("tip not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tipRecord.accepted = true;
|
||||||
|
await oneShotPut(ws.db, Stores.tips, tipRecord);
|
||||||
|
|
||||||
|
await processTip(ws, tipId);
|
||||||
|
return;
|
||||||
|
}
|
577
src/wallet-impl/withdraw.ts
Normal file
577
src/wallet-impl/withdraw.ts
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
/*
|
||||||
|
This file is part of GNU Taler
|
||||||
|
(C) 2019 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/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AmountJson } from "../util/amounts";
|
||||||
|
import {
|
||||||
|
DenominationRecord,
|
||||||
|
Stores,
|
||||||
|
DenominationStatus,
|
||||||
|
CoinStatus,
|
||||||
|
CoinRecord,
|
||||||
|
PlanchetRecord,
|
||||||
|
} from "../dbTypes";
|
||||||
|
import * as Amounts from "../util/amounts";
|
||||||
|
import {
|
||||||
|
getTimestampNow,
|
||||||
|
AcceptWithdrawalResponse,
|
||||||
|
DownloadedWithdrawInfo,
|
||||||
|
ReserveCreationInfo,
|
||||||
|
WithdrawDetails,
|
||||||
|
} from "../walletTypes";
|
||||||
|
import { WithdrawOperationStatusResponse } from "../talerTypes";
|
||||||
|
import { InternalWalletState } from "./state";
|
||||||
|
import { parseWithdrawUri } from "../util/taleruri";
|
||||||
|
import { Logger } from "../util/logging";
|
||||||
|
import {
|
||||||
|
oneShotGet,
|
||||||
|
oneShotPut,
|
||||||
|
oneShotIterIndex,
|
||||||
|
oneShotGetIndexed,
|
||||||
|
runWithWriteTransaction,
|
||||||
|
} from "../util/query";
|
||||||
|
import {
|
||||||
|
updateExchangeFromUrl,
|
||||||
|
getExchangePaytoUri,
|
||||||
|
getExchangeTrust,
|
||||||
|
} from "./exchanges";
|
||||||
|
import { createReserve, processReserveBankStatus } from "./reserves";
|
||||||
|
import { WALLET_PROTOCOL_VERSION } from "../wallet";
|
||||||
|
|
||||||
|
import * as LibtoolVersion from "../util/libtoolVersion";
|
||||||
|
|
||||||
|
const logger = new Logger("withdraw.ts");
|
||||||
|
|
||||||
|
function isWithdrawableDenom(d: DenominationRecord) {
|
||||||
|
const now = getTimestampNow();
|
||||||
|
const started = now.t_ms >= d.stampStart.t_ms;
|
||||||
|
const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms;
|
||||||
|
return started && stillOkay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of denominations (with repetitions possible)
|
||||||
|
* whose total value is as close as possible to the available
|
||||||
|
* amount, but never larger.
|
||||||
|
*/
|
||||||
|
export function getWithdrawDenomList(
|
||||||
|
amountAvailable: AmountJson,
|
||||||
|
denoms: DenominationRecord[],
|
||||||
|
): DenominationRecord[] {
|
||||||
|
let remaining = Amounts.copy(amountAvailable);
|
||||||
|
const ds: DenominationRecord[] = [];
|
||||||
|
|
||||||
|
denoms = denoms.filter(isWithdrawableDenom);
|
||||||
|
denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
|
||||||
|
|
||||||
|
// This is an arbitrary number of coins
|
||||||
|
// we can withdraw in one go. It's not clear if this limit
|
||||||
|
// is useful ...
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
let found = false;
|
||||||
|
for (const d of denoms) {
|
||||||
|
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
|
||||||
|
if (Amounts.cmp(remaining, cost) < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
remaining = Amounts.sub(remaining, cost).amount;
|
||||||
|
ds.push(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get information about a withdrawal from
|
||||||
|
* a taler://withdraw URI.
|
||||||
|
*/
|
||||||
|
export async function getWithdrawalInfo(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerWithdrawUri: string,
|
||||||
|
): Promise<DownloadedWithdrawInfo> {
|
||||||
|
const uriResult = parseWithdrawUri(talerWithdrawUri);
|
||||||
|
if (!uriResult) {
|
||||||
|
throw Error("can't parse URL");
|
||||||
|
}
|
||||||
|
const resp = await ws.http.get(uriResult.statusUrl);
|
||||||
|
console.log("resp:", resp.responseJson);
|
||||||
|
const status = WithdrawOperationStatusResponse.checked(resp.responseJson);
|
||||||
|
return {
|
||||||
|
amount: Amounts.parseOrThrow(status.amount),
|
||||||
|
confirmTransferUrl: status.confirm_transfer_url,
|
||||||
|
extractedStatusUrl: uriResult.statusUrl,
|
||||||
|
selectionDone: status.selection_done,
|
||||||
|
senderWire: status.sender_wire,
|
||||||
|
suggestedExchange: status.suggested_exchange,
|
||||||
|
transferDone: status.transfer_done,
|
||||||
|
wireTypes: status.wire_types,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptWithdrawal(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerWithdrawUri: string,
|
||||||
|
selectedExchange: string,
|
||||||
|
): Promise<AcceptWithdrawalResponse> {
|
||||||
|
const withdrawInfo = await getWithdrawalInfo(ws, talerWithdrawUri);
|
||||||
|
const exchangeWire = await getExchangePaytoUri(
|
||||||
|
ws,
|
||||||
|
selectedExchange,
|
||||||
|
withdrawInfo.wireTypes,
|
||||||
|
);
|
||||||
|
const reserve = await createReserve(ws, {
|
||||||
|
amount: withdrawInfo.amount,
|
||||||
|
bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
|
||||||
|
exchange: selectedExchange,
|
||||||
|
senderWire: withdrawInfo.senderWire,
|
||||||
|
exchangeWire: exchangeWire,
|
||||||
|
});
|
||||||
|
// We do this here, as the reserve should be registered before we return,
|
||||||
|
// so that we can redirect the user to the bank's status page.
|
||||||
|
await processReserveBankStatus(ws, reserve.reservePub);
|
||||||
|
console.log("acceptWithdrawal: returning");
|
||||||
|
return {
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPossibleDenoms(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
): Promise<DenominationRecord[]> {
|
||||||
|
return await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
exchangeBaseUrl,
|
||||||
|
).filter(d => {
|
||||||
|
return (
|
||||||
|
d.status === DenominationStatus.Unverified ||
|
||||||
|
d.status === DenominationStatus.VerifiedGood
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a planchet, withdraw a coin from the exchange.
|
||||||
|
*/
|
||||||
|
async function processPlanchet(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
withdrawalSessionId: string,
|
||||||
|
coinIdx: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const withdrawalSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
withdrawalSessionId,
|
||||||
|
);
|
||||||
|
if (!withdrawalSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (withdrawalSession.withdrawn[coinIdx]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (withdrawalSession.source.type === "reserve") {
|
||||||
|
|
||||||
|
}
|
||||||
|
const planchet = withdrawalSession.planchets[coinIdx];
|
||||||
|
if (!planchet) {
|
||||||
|
console.log("processPlanchet: planchet not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exchange = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.exchanges,
|
||||||
|
withdrawalSession.exchangeBaseUrl,
|
||||||
|
);
|
||||||
|
if (!exchange) {
|
||||||
|
console.error("db inconsistent: exchange for planchet not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
withdrawalSession.exchangeBaseUrl,
|
||||||
|
planchet.denomPub,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!denom) {
|
||||||
|
console.error("db inconsistent: denom for planchet not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wd: any = {};
|
||||||
|
wd.denom_pub_hash = planchet.denomPubHash;
|
||||||
|
wd.reserve_pub = planchet.reservePub;
|
||||||
|
wd.reserve_sig = planchet.withdrawSig;
|
||||||
|
wd.coin_ev = planchet.coinEv;
|
||||||
|
const reqUrl = new URL("reserve/withdraw", exchange.baseUrl).href;
|
||||||
|
const resp = await ws.http.postJson(reqUrl, wd);
|
||||||
|
|
||||||
|
const r = resp.responseJson;
|
||||||
|
|
||||||
|
const denomSig = await ws.cryptoApi.rsaUnblind(
|
||||||
|
r.ev_sig,
|
||||||
|
planchet.blindingKey,
|
||||||
|
planchet.denomPub,
|
||||||
|
);
|
||||||
|
|
||||||
|
const coin: CoinRecord = {
|
||||||
|
blindingKey: planchet.blindingKey,
|
||||||
|
coinPriv: planchet.coinPriv,
|
||||||
|
coinPub: planchet.coinPub,
|
||||||
|
currentAmount: planchet.coinValue,
|
||||||
|
denomPub: planchet.denomPub,
|
||||||
|
denomPubHash: planchet.denomPubHash,
|
||||||
|
denomSig,
|
||||||
|
exchangeBaseUrl: withdrawalSession.exchangeBaseUrl,
|
||||||
|
reservePub: planchet.reservePub,
|
||||||
|
status: CoinStatus.Fresh,
|
||||||
|
coinIndex: coinIdx,
|
||||||
|
withdrawSessionId: withdrawalSessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.coins, Stores.withdrawalSession, Stores.reserves],
|
||||||
|
async tx => {
|
||||||
|
const ws = await tx.get(
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
withdrawalSessionId,
|
||||||
|
);
|
||||||
|
if (!ws) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws.withdrawn[coinIdx]) {
|
||||||
|
// Already withdrawn
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.withdrawn[coinIdx] = true;
|
||||||
|
await tx.put(Stores.withdrawalSession, ws);
|
||||||
|
if (!planchet.isFromTip) {
|
||||||
|
const r = await tx.get(Stores.reserves, planchet.reservePub);
|
||||||
|
if (r) {
|
||||||
|
r.withdrawCompletedAmount = Amounts.add(
|
||||||
|
r.withdrawCompletedAmount,
|
||||||
|
Amounts.add(denom.value, denom.feeWithdraw).amount,
|
||||||
|
).amount;
|
||||||
|
await tx.put(Stores.reserves, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.add(Stores.coins, coin);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.notifier.notify();
|
||||||
|
logger.trace(`withdraw of one coin ${coin.coinPub} finished`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of denominations to withdraw from the given exchange for the
|
||||||
|
* given amount, making sure that all denominations' signatures are verified.
|
||||||
|
*
|
||||||
|
* Writes to the DB in order to record the result from verifying
|
||||||
|
* denominations.
|
||||||
|
*/
|
||||||
|
export async function getVerifiedWithdrawDenomList(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
exchangeBaseUrl: string,
|
||||||
|
amount: AmountJson,
|
||||||
|
): Promise<DenominationRecord[]> {
|
||||||
|
const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl);
|
||||||
|
if (!exchange) {
|
||||||
|
console.log("exchange not found");
|
||||||
|
throw Error(`exchange ${exchangeBaseUrl} not found`);
|
||||||
|
}
|
||||||
|
const exchangeDetails = exchange.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
console.log("exchange details not available");
|
||||||
|
throw Error(`exchange ${exchangeBaseUrl} details not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("getting possible denoms");
|
||||||
|
|
||||||
|
const possibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl);
|
||||||
|
|
||||||
|
console.log("got possible denoms");
|
||||||
|
|
||||||
|
let allValid = false;
|
||||||
|
|
||||||
|
let selectedDenoms: DenominationRecord[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
allValid = true;
|
||||||
|
const nextPossibleDenoms = [];
|
||||||
|
selectedDenoms = getWithdrawDenomList(amount, possibleDenoms);
|
||||||
|
console.log("got withdraw denom list");
|
||||||
|
for (const denom of selectedDenoms || []) {
|
||||||
|
if (denom.status === DenominationStatus.Unverified) {
|
||||||
|
console.log(
|
||||||
|
"checking validity",
|
||||||
|
denom,
|
||||||
|
exchangeDetails.masterPublicKey,
|
||||||
|
);
|
||||||
|
const valid = await ws.cryptoApi.isValidDenom(
|
||||||
|
denom,
|
||||||
|
exchangeDetails.masterPublicKey,
|
||||||
|
);
|
||||||
|
console.log("done checking validity");
|
||||||
|
if (!valid) {
|
||||||
|
denom.status = DenominationStatus.VerifiedBad;
|
||||||
|
allValid = false;
|
||||||
|
} else {
|
||||||
|
denom.status = DenominationStatus.VerifiedGood;
|
||||||
|
nextPossibleDenoms.push(denom);
|
||||||
|
}
|
||||||
|
await oneShotPut(ws.db, Stores.denominations, denom);
|
||||||
|
} else {
|
||||||
|
nextPossibleDenoms.push(denom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (selectedDenoms.length > 0 && !allValid);
|
||||||
|
|
||||||
|
console.log("returning denoms");
|
||||||
|
|
||||||
|
return selectedDenoms;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithdrawCoin(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
withdrawalSessionId: string,
|
||||||
|
coinIndex: number,
|
||||||
|
) {
|
||||||
|
logger.info("starting withdraw for coin");
|
||||||
|
const withdrawalSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
withdrawalSessionId,
|
||||||
|
);
|
||||||
|
if (!withdrawalSession) {
|
||||||
|
console.log("ws doesn't exist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coin = await oneShotGetIndexed(
|
||||||
|
ws.db,
|
||||||
|
Stores.coins.byWithdrawalWithIdx,
|
||||||
|
[withdrawalSessionId, coinIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (coin) {
|
||||||
|
console.log("coin already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withdrawalSession.planchets[coinIndex]) {
|
||||||
|
return processPlanchet(ws, withdrawalSessionId, coinIndex);
|
||||||
|
} else {
|
||||||
|
const src = withdrawalSession.source;
|
||||||
|
if (src.type !== "reserve") {
|
||||||
|
throw Error("invalid state");
|
||||||
|
}
|
||||||
|
const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub)
|
||||||
|
if (!reserve) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
||||||
|
withdrawalSession.exchangeBaseUrl,
|
||||||
|
withdrawalSession.denoms[coinIndex],
|
||||||
|
]);
|
||||||
|
if (!denom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await ws.cryptoApi.createPlanchet({
|
||||||
|
denomPub: denom.denomPub,
|
||||||
|
feeWithdraw: denom.feeWithdraw,
|
||||||
|
reservePriv: reserve.reservePriv,
|
||||||
|
reservePub: reserve.reservePub,
|
||||||
|
value: denom.value,
|
||||||
|
});
|
||||||
|
const newPlanchet: PlanchetRecord = {
|
||||||
|
blindingKey: r.blindingKey,
|
||||||
|
coinEv: r.coinEv,
|
||||||
|
coinPriv: r.coinPriv,
|
||||||
|
coinPub: r.coinPub,
|
||||||
|
coinValue: r.coinValue,
|
||||||
|
denomPub: r.denomPub,
|
||||||
|
denomPubHash: r.denomPubHash,
|
||||||
|
isFromTip: false,
|
||||||
|
reservePub: r.reservePub,
|
||||||
|
withdrawSig: r.withdrawSig,
|
||||||
|
};
|
||||||
|
await runWithWriteTransaction(
|
||||||
|
ws.db,
|
||||||
|
[Stores.withdrawalSession],
|
||||||
|
async tx => {
|
||||||
|
const myWs = await tx.get(
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
withdrawalSessionId,
|
||||||
|
);
|
||||||
|
if (!myWs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (myWs.planchets[coinIndex]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
myWs.planchets[coinIndex] = newPlanchet;
|
||||||
|
await tx.put(Stores.withdrawalSession, myWs);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await processPlanchet(ws, withdrawalSessionId, coinIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processWithdrawSession(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
withdrawalSessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.trace("processing withdraw session", withdrawalSessionId);
|
||||||
|
const withdrawalSession = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.withdrawalSession,
|
||||||
|
withdrawalSessionId,
|
||||||
|
);
|
||||||
|
if (!withdrawalSession) {
|
||||||
|
logger.trace("withdraw session doesn't exist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ps = withdrawalSession.denoms.map((d, i) =>
|
||||||
|
processWithdrawCoin(ws, withdrawalSessionId, i),
|
||||||
|
);
|
||||||
|
await Promise.all(ps);
|
||||||
|
ws.badge.showNotification();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWithdrawDetailsForAmount(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
baseUrl: string,
|
||||||
|
amount: AmountJson,
|
||||||
|
): Promise<ReserveCreationInfo> {
|
||||||
|
const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl);
|
||||||
|
const exchangeDetails = exchangeInfo.details;
|
||||||
|
if (!exchangeDetails) {
|
||||||
|
throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
|
||||||
|
}
|
||||||
|
const exchangeWireInfo = exchangeInfo.wireInfo;
|
||||||
|
if (!exchangeWireInfo) {
|
||||||
|
throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDenoms = await getVerifiedWithdrawDenomList(
|
||||||
|
ws,
|
||||||
|
baseUrl,
|
||||||
|
amount,
|
||||||
|
);
|
||||||
|
let acc = Amounts.getZero(amount.currency);
|
||||||
|
for (const d of selectedDenoms) {
|
||||||
|
acc = Amounts.add(acc, d.feeWithdraw).amount;
|
||||||
|
}
|
||||||
|
const actualCoinCost = selectedDenoms
|
||||||
|
.map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount)
|
||||||
|
.reduce((a, b) => Amounts.add(a, b).amount);
|
||||||
|
|
||||||
|
const exchangeWireAccounts: string[] = [];
|
||||||
|
for (let account of exchangeWireInfo.accounts) {
|
||||||
|
exchangeWireAccounts.push(account.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
|
||||||
|
|
||||||
|
let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit;
|
||||||
|
for (let i = 1; i < selectedDenoms.length; i++) {
|
||||||
|
const expireDeposit = selectedDenoms[i].stampExpireDeposit;
|
||||||
|
if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
|
||||||
|
earliestDepositExpiration = expireDeposit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleDenoms = await oneShotIterIndex(
|
||||||
|
ws.db,
|
||||||
|
Stores.denominations.exchangeBaseUrlIndex,
|
||||||
|
baseUrl,
|
||||||
|
).filter(d => d.isOffered);
|
||||||
|
|
||||||
|
const trustedAuditorPubs = [];
|
||||||
|
const currencyRecord = await oneShotGet(
|
||||||
|
ws.db,
|
||||||
|
Stores.currencies,
|
||||||
|
amount.currency,
|
||||||
|
);
|
||||||
|
if (currencyRecord) {
|
||||||
|
trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub));
|
||||||
|
}
|
||||||
|
|
||||||
|
let versionMatch;
|
||||||
|
if (exchangeDetails.protocolVersion) {
|
||||||
|
versionMatch = LibtoolVersion.compare(
|
||||||
|
WALLET_PROTOCOL_VERSION,
|
||||||
|
exchangeDetails.protocolVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
versionMatch &&
|
||||||
|
!versionMatch.compatible &&
|
||||||
|
versionMatch.currentCmp === -1
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` +
|
||||||
|
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret: ReserveCreationInfo = {
|
||||||
|
earliestDepositExpiration,
|
||||||
|
exchangeInfo,
|
||||||
|
exchangeWireAccounts,
|
||||||
|
exchangeVersion: exchangeDetails.protocolVersion || "unknown",
|
||||||
|
isAudited,
|
||||||
|
isTrusted,
|
||||||
|
numOfferedDenoms: possibleDenoms.length,
|
||||||
|
overhead: Amounts.sub(amount, actualCoinCost).amount,
|
||||||
|
selectedDenoms,
|
||||||
|
trustedAuditorPubs,
|
||||||
|
versionMatch,
|
||||||
|
walletVersion: WALLET_PROTOCOL_VERSION,
|
||||||
|
wireFees: exchangeWireInfo,
|
||||||
|
withdrawFee: acc,
|
||||||
|
};
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWithdrawDetailsForUri(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
talerWithdrawUri: string,
|
||||||
|
maybeSelectedExchange?: string,
|
||||||
|
): Promise<WithdrawDetails> {
|
||||||
|
const info = await getWithdrawalInfo(ws, talerWithdrawUri);
|
||||||
|
let rci: ReserveCreationInfo | undefined = undefined;
|
||||||
|
if (maybeSelectedExchange) {
|
||||||
|
rci = await getWithdrawDetailsForAmount(
|
||||||
|
ws,
|
||||||
|
maybeSelectedExchange,
|
||||||
|
info.amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
withdrawInfo: info,
|
||||||
|
reserveCreationInfo: rci,
|
||||||
|
};
|
||||||
|
}
|
@ -14,7 +14,6 @@
|
|||||||
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
import test from "ava";
|
import test from "ava";
|
||||||
|
|
||||||
import * as dbTypes from "./dbTypes";
|
import * as dbTypes from "./dbTypes";
|
||||||
@ -22,9 +21,9 @@ import * as types from "./walletTypes";
|
|||||||
|
|
||||||
import * as wallet from "./wallet";
|
import * as wallet from "./wallet";
|
||||||
|
|
||||||
import { AmountJson} from "./amounts";
|
import { AmountJson } from "./util/amounts";
|
||||||
import * as Amounts from "./amounts";
|
import * as Amounts from "./util/amounts";
|
||||||
|
import { selectPayCoins } from "./wallet-impl/pay";
|
||||||
|
|
||||||
function a(x: string): AmountJson {
|
function a(x: string): AmountJson {
|
||||||
const amt = Amounts.parse(x);
|
const amt = Amounts.parse(x);
|
||||||
@ -34,8 +33,11 @@ function a(x: string): AmountJson {
|
|||||||
return amt;
|
return amt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fakeCwd(
|
||||||
function fakeCwd(current: string, value: string, feeDeposit: string): types.CoinWithDenom {
|
current: string,
|
||||||
|
value: string,
|
||||||
|
feeDeposit: string,
|
||||||
|
): types.CoinWithDenom {
|
||||||
return {
|
return {
|
||||||
coin: {
|
coin: {
|
||||||
blindingKey: "(mock)",
|
blindingKey: "(mock)",
|
||||||
@ -71,14 +73,13 @@ function fakeCwd(current: string, value: string, feeDeposit: string): types.Coin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("coin selection 1", t => {
|
||||||
test("coin selection 1", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1"));
|
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1"));
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -87,15 +88,14 @@ test("coin selection 1", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin selection 2", t => {
|
||||||
test("coin selection 2", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
||||||
// Merchant covers the fee, this one shouldn't be used
|
// Merchant covers the fee, this one shouldn't be used
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5"));
|
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5"));
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -104,15 +104,14 @@ test("coin selection 2", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin selection 3", t => {
|
||||||
test("coin selection 3", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
// this coin should be selected instead of previous one with fee
|
// this coin should be selected instead of previous one with fee
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
|
||||||
];
|
];
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5"));
|
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5"));
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -121,14 +120,13 @@ test("coin selection 3", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin selection 4", t => {
|
||||||
test("coin selection 4", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2"));
|
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2"));
|
||||||
if (!res) {
|
if (!res) {
|
||||||
t.fail();
|
t.fail();
|
||||||
return;
|
return;
|
||||||
@ -137,25 +135,23 @@ test("coin selection 4", (t) => {
|
|||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin selection 5", t => {
|
||||||
test("coin selection 5", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2"));
|
const res = selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2"));
|
||||||
t.true(!res);
|
t.true(!res);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin selection 6", t => {
|
||||||
test("coin selection 6", (t) => {
|
|
||||||
const cds: types.CoinWithDenom[] = [
|
const cds: types.CoinWithDenom[] = [
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
|
||||||
];
|
];
|
||||||
const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2"));
|
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2"));
|
||||||
t.true(!res);
|
t.true(!res);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
3769
src/wallet.ts
3769
src/wallet.ts
File diff suppressed because it is too large
Load Diff
@ -25,16 +25,17 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { Checkable } from "./checkable";
|
import { Checkable } from "./util/checkable";
|
||||||
import * as LibtoolVersion from "./libtoolVersion";
|
import * as LibtoolVersion from "./util/libtoolVersion";
|
||||||
|
|
||||||
import { AmountJson } from "./amounts";
|
import { AmountJson } from "./util/amounts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
ExchangeRecord,
|
ExchangeRecord,
|
||||||
ExchangeWireInfo,
|
ExchangeWireInfo,
|
||||||
|
WithdrawalSource,
|
||||||
} from "./dbTypes";
|
} from "./dbTypes";
|
||||||
import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes";
|
import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes";
|
||||||
|
|
||||||
@ -413,6 +414,7 @@ export interface TipStatus {
|
|||||||
nextUrl: string;
|
nextUrl: string;
|
||||||
exchangeUrl: string;
|
exchangeUrl: string;
|
||||||
tipId: string;
|
tipId: string;
|
||||||
|
merchantTipId: string;
|
||||||
merchantOrigin: string;
|
merchantOrigin: string;
|
||||||
expirationTimestamp: number;
|
expirationTimestamp: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@ -523,7 +525,7 @@ export interface WalletDiagnostics {
|
|||||||
|
|
||||||
export interface PendingWithdrawOperation {
|
export interface PendingWithdrawOperation {
|
||||||
type: "withdraw";
|
type: "withdraw";
|
||||||
reservePub: string;
|
source: WithdrawalSource,
|
||||||
withdrawSessionId: string;
|
withdrawSessionId: string;
|
||||||
numCoinsWithdrawn: number;
|
numCoinsWithdrawn: number;
|
||||||
numCoinsTotal: number;
|
numCoinsTotal: number;
|
||||||
@ -576,13 +578,6 @@ export interface PendingRefreshOperation {
|
|||||||
refreshOutputSize: number;
|
refreshOutputSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingPlanchetOperation {
|
|
||||||
type: "planchet";
|
|
||||||
coinPub: string;
|
|
||||||
reservePub: string;
|
|
||||||
lastError?: OperationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingDirtyCoinOperation {
|
export interface PendingDirtyCoinOperation {
|
||||||
type: "dirty-coin";
|
type: "dirty-coin";
|
||||||
coinPub: string;
|
coinPub: string;
|
||||||
@ -595,14 +590,21 @@ export interface PendingProposalOperation {
|
|||||||
proposalId: string;
|
proposalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingTipOperation {
|
||||||
|
type: "tip";
|
||||||
|
tipId: string;
|
||||||
|
merchantBaseUrl: string;
|
||||||
|
merchantTipId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PendingOperationInfo =
|
export type PendingOperationInfo =
|
||||||
| PendingWithdrawOperation
|
| PendingWithdrawOperation
|
||||||
| PendingReserveOperation
|
| PendingReserveOperation
|
||||||
| PendingBugOperation
|
| PendingBugOperation
|
||||||
| PendingPlanchetOperation
|
|
||||||
| PendingDirtyCoinOperation
|
| PendingDirtyCoinOperation
|
||||||
| PendingExchangeUpdateOperation
|
| PendingExchangeUpdateOperation
|
||||||
| PendingRefreshOperation
|
| PendingRefreshOperation
|
||||||
|
| PendingTipOperation
|
||||||
| PendingProposalOperation;
|
| PendingProposalOperation;
|
||||||
|
|
||||||
export interface PendingOperationsResponse {
|
export interface PendingOperationsResponse {
|
||||||
@ -642,7 +644,6 @@ export function getTimestampNow(): Timestamp {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface PlanchetCreationResult {
|
export interface PlanchetCreationResult {
|
||||||
coinPub: string;
|
coinPub: string;
|
||||||
coinPriv: string;
|
coinPriv: string;
|
||||||
@ -652,6 +653,13 @@ export interface PlanchetCreationResult {
|
|||||||
blindingKey: string;
|
blindingKey: string;
|
||||||
withdrawSig: string;
|
withdrawSig: string;
|
||||||
coinEv: string;
|
coinEv: string;
|
||||||
exchangeBaseUrl: string;
|
|
||||||
coinValue: AmountJson;
|
coinValue: AmountJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanchetCreationRequest {
|
||||||
|
value: AmountJson;
|
||||||
|
feeWithdraw: AmountJson;
|
||||||
|
denomPub: string;
|
||||||
|
reservePub: string;
|
||||||
|
reservePriv: string;
|
||||||
}
|
}
|
@ -21,7 +21,7 @@
|
|||||||
// Messages are already documented in wxApi.
|
// Messages are already documented in wxApi.
|
||||||
/* tslint:disable:completed-docs */
|
/* tslint:disable:completed-docs */
|
||||||
|
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import * as dbTypes from "../dbTypes";
|
import * as dbTypes from "../dbTypes";
|
||||||
import * as talerTypes from "../talerTypes";
|
import * as talerTypes from "../talerTypes";
|
||||||
import * as walletTypes from "../walletTypes";
|
import * as walletTypes from "../walletTypes";
|
||||||
@ -113,10 +113,6 @@ export interface MessageMap {
|
|||||||
request: { reservePub: string };
|
request: { reservePub: string };
|
||||||
response: dbTypes.ReserveRecord[];
|
response: dbTypes.ReserveRecord[];
|
||||||
};
|
};
|
||||||
"get-planchets": {
|
|
||||||
request: { exchangeBaseUrl: string };
|
|
||||||
response: dbTypes.PlanchetRecord[];
|
|
||||||
};
|
|
||||||
"get-denoms": {
|
"get-denoms": {
|
||||||
request: { exchangeBaseUrl: string };
|
request: { exchangeBaseUrl: string };
|
||||||
response: dbTypes.DenominationRecord[];
|
response: dbTypes.DenominationRecord[];
|
||||||
@ -153,14 +149,6 @@ export interface MessageMap {
|
|||||||
request: {};
|
request: {};
|
||||||
response: void;
|
response: void;
|
||||||
};
|
};
|
||||||
"download-proposal": {
|
|
||||||
request: { url: string };
|
|
||||||
response: number;
|
|
||||||
};
|
|
||||||
"submit-pay": {
|
|
||||||
request: { contractTermsHash: string; sessionId: string | undefined };
|
|
||||||
response: walletTypes.ConfirmPayResult;
|
|
||||||
};
|
|
||||||
"accept-refund": {
|
"accept-refund": {
|
||||||
request: { refundUrl: string };
|
request: { refundUrl: string };
|
||||||
response: string;
|
response: string;
|
||||||
|
@ -24,8 +24,6 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
import wxApi = require("./wxApi");
|
import wxApi = require("./wxApi");
|
||||||
|
|
||||||
declare var cloneInto: any;
|
declare var cloneInto: any;
|
||||||
@ -180,25 +178,19 @@ function registerHandlers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addHandler("taler-create-reserve", (msg: any) => {
|
addHandler("taler-create-reserve", (msg: any) => {
|
||||||
const params = {
|
const uri = new URL(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html"));
|
||||||
amount: JSON.stringify(msg.amount),
|
uri.searchParams.set("amount", JSON.stringify(msg.amount));
|
||||||
bank_url: document.location.href,
|
uri.searchParams.set("bank_url", document.location.href);
|
||||||
callback_url: new URI(msg.callback_url) .absoluteTo(document.location.href),
|
uri.searchParams.set("callback_url", new URL(msg.callback_url, document.location.href).href);
|
||||||
suggested_exchange_url: msg.suggested_exchange_url,
|
uri.searchParams.set("suggested_exchange_url", msg.suggested_exchange_url);
|
||||||
wt_types: JSON.stringify(msg.wt_types),
|
uri.searchParams.set("wt_types", JSON.stringify(msg.wt_types));
|
||||||
};
|
window.location.href = uri.href;
|
||||||
const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html"));
|
|
||||||
const redirectUrl = uri.query(params).href();
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addHandler("taler-add-auditor", (msg: any) => {
|
addHandler("taler-add-auditor", (msg: any) => {
|
||||||
const params = {
|
const uri = new URL(chrome.extension.getURL("/src/webex/pages/add-auditor.html"));
|
||||||
req: JSON.stringify(msg),
|
uri.searchParams.set("req", JSON.stringify(msg))
|
||||||
};
|
window.location.href = uri.href;
|
||||||
const uri = new URI(chrome.extension.getURL("/src/webex/pages/add-auditor.html"));
|
|
||||||
const redirectUrl = uri.query(params).href();
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addHandler("taler-confirm-reserve", async (msg: any, sendResponse: any) => {
|
addHandler("taler-confirm-reserve", async (msg: any, sendResponse: any) => {
|
||||||
|
@ -23,7 +23,6 @@
|
|||||||
import { CurrencyRecord } from "../../dbTypes";
|
import { CurrencyRecord } from "../../dbTypes";
|
||||||
import { getCurrencies, updateCurrency } from "../wxApi";
|
import { getCurrencies, updateCurrency } from "../wxApi";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import URI = require("urijs");
|
|
||||||
import { registerMountPage } from "../renderHtml";
|
import { registerMountPage } from "../renderHtml";
|
||||||
|
|
||||||
interface ConfirmAuditorProps {
|
interface ConfirmAuditorProps {
|
||||||
@ -118,14 +117,24 @@ function ConfirmAuditor(props: ConfirmAuditorProps) {
|
|||||||
|
|
||||||
|
|
||||||
registerMountPage(() => {
|
registerMountPage(() => {
|
||||||
const walletPageUrl = new URI(document.location.href);
|
const walletPageUrl = new URL(document.location.href);
|
||||||
const query: any = JSON.parse(
|
const url = walletPageUrl.searchParams.get("url");
|
||||||
(URI.parseQuery(walletPageUrl.query()) as any).req,
|
if (!url) {
|
||||||
);
|
throw Error("missign parameter (url)");
|
||||||
const url = query.url;
|
}
|
||||||
const currency: string = query.currency;
|
const currency = walletPageUrl.searchParams.get("currency");
|
||||||
const auditorPub: string = query.auditorPub;
|
if (!currency) {
|
||||||
const expirationStamp = Number.parseInt(query.expirationStamp);
|
throw Error("missing parameter (currency)");
|
||||||
|
}
|
||||||
|
const auditorPub = walletPageUrl.searchParams.get("auditorPub");
|
||||||
|
if (!auditorPub) {
|
||||||
|
throw Error("missing parameter (auditorPub)");
|
||||||
|
}
|
||||||
|
const auditorStampStr = walletPageUrl.searchParams.get("expirationStamp");
|
||||||
|
if (!auditorStampStr) {
|
||||||
|
throw Error("missing parameter (auditorStampStr)");
|
||||||
|
}
|
||||||
|
const expirationStamp = Number.parseInt(auditorStampStr);
|
||||||
const args = { url, currency, auditorPub, expirationStamp };
|
const args = { url, currency, auditorPub, expirationStamp };
|
||||||
return <ConfirmAuditor {...args}/>;
|
return <ConfirmAuditor {...args}/>;
|
||||||
});
|
});
|
||||||
|
@ -30,9 +30,8 @@ import { renderAmount, ProgressButton, registerMountPage } from "../renderHtml";
|
|||||||
import * as wxApi from "../wxApi";
|
import * as wxApi from "../wxApi";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
import * as Amounts from "../../amounts";
|
import * as Amounts from "../../util/amounts";
|
||||||
|
|
||||||
function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) {
|
function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) {
|
||||||
const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>();
|
const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>();
|
||||||
@ -164,10 +163,10 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerMountPage(() => {
|
registerMountPage(() => {
|
||||||
const url = new URI(document.location.href);
|
const url = new URL(document.location.href);
|
||||||
const query: any = URI.parseQuery(url.query());
|
const talerPayUri = url.searchParams.get("talerPayUri");
|
||||||
|
if (!talerPayUri) {
|
||||||
let talerPayUri = query.talerPayUri;
|
throw Error("invalid parameter");
|
||||||
|
}
|
||||||
return <TalerPayDialog talerPayUri={talerPayUri} />;
|
return <TalerPayDialog talerPayUri={talerPayUri} />;
|
||||||
});
|
});
|
||||||
|
@ -26,8 +26,8 @@
|
|||||||
*/
|
*/
|
||||||
import * as i18n from "../../i18n";
|
import * as i18n from "../../i18n";
|
||||||
|
|
||||||
import { AmountJson } from "../../amounts";
|
import { AmountJson } from "../../util/amounts";
|
||||||
import * as Amounts from "../../amounts";
|
import * as Amounts from "../../util/amounts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoryEvent,
|
HistoryEvent,
|
||||||
@ -44,9 +44,6 @@ import {
|
|||||||
import * as wxApi from "../wxApi";
|
import * as wxApi from "../wxApi";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ReactDOM from "react-dom";
|
|
||||||
|
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
function onUpdateNotification(f: () => void): () => void {
|
function onUpdateNotification(f: () => void): () => void {
|
||||||
const port = chrome.runtime.connect({ name: "notifications" });
|
const port = chrome.runtime.connect({ name: "notifications" });
|
||||||
@ -339,7 +336,7 @@ function formatHistoryItem(historyItem: HistoryEvent) {
|
|||||||
</i18n.Translate>
|
</i18n.Translate>
|
||||||
);
|
);
|
||||||
case "confirm-reserve": {
|
case "confirm-reserve": {
|
||||||
const exchange = new URI(d.exchangeBaseUrl).host();
|
const exchange = new URL(d.exchangeBaseUrl).host;
|
||||||
const pub = abbrev(d.reservePub);
|
const pub = abbrev(d.reservePub);
|
||||||
return (
|
return (
|
||||||
<i18n.Translate wrap="p">
|
<i18n.Translate wrap="p">
|
||||||
@ -359,7 +356,7 @@ function formatHistoryItem(historyItem: HistoryEvent) {
|
|||||||
}
|
}
|
||||||
case "depleted-reserve": {
|
case "depleted-reserve": {
|
||||||
const exchange = d.exchangeBaseUrl
|
const exchange = d.exchangeBaseUrl
|
||||||
? new URI(d.exchangeBaseUrl).host()
|
? new URL(d.exchangeBaseUrl).host
|
||||||
: "??";
|
: "??";
|
||||||
const amount = renderAmount(d.requestedAmount);
|
const amount = renderAmount(d.requestedAmount);
|
||||||
const pub = abbrev(d.reservePub);
|
const pub = abbrev(d.reservePub);
|
||||||
@ -396,11 +393,10 @@ function formatHistoryItem(historyItem: HistoryEvent) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tip": {
|
case "tip": {
|
||||||
const tipPageUrl = new URI(
|
const tipPageUrl = new URL(chrome.extension.getURL("/src/webex/pages/tip.html"));
|
||||||
chrome.extension.getURL("/src/webex/pages/tip.html"),
|
tipPageUrl.searchParams.set("tip_id", d.tipId);
|
||||||
);
|
tipPageUrl.searchParams.set("merchant_domain", d.merchantDomain);
|
||||||
const params = { tip_id: d.tipId, merchant_domain: d.merchantDomain };
|
const url = tipPageUrl.href;
|
||||||
const url = tipPageUrl.query(params).href();
|
|
||||||
const tipLink = <a href={url} onClick={openTab(url)}>{i18n.str`tip`}</a>;
|
const tipLink = <a href={url} onClick={openTab(url)}>{i18n.str`tip`}</a>;
|
||||||
// i18n: Tip
|
// i18n: Tip
|
||||||
return (
|
return (
|
||||||
|
@ -22,7 +22,6 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
import * as wxApi from "../wxApi";
|
import * as wxApi from "../wxApi";
|
||||||
import { PurchaseDetails } from "../../walletTypes";
|
import { PurchaseDetails } from "../../walletTypes";
|
||||||
@ -76,8 +75,7 @@ function RefundStatusView(props: { talerRefundUri: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const url = new URI(document.location.href);
|
const url = new URL(document.location.href);
|
||||||
const query: any = URI.parseQuery(url.query());
|
|
||||||
|
|
||||||
const container = document.getElementById("container");
|
const container = document.getElementById("container");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@ -85,7 +83,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const talerRefundUri = query.talerRefundUri;
|
const talerRefundUri = url.searchParams.get("talerRefundUri");
|
||||||
if (!talerRefundUri) {
|
if (!talerRefundUri) {
|
||||||
console.error("taler refund URI requred");
|
console.error("taler refund URI requred");
|
||||||
return;
|
return;
|
||||||
|
@ -25,8 +25,8 @@
|
|||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AmountJson } from "../../amounts";
|
import { AmountJson } from "../../util/amounts";
|
||||||
import * as Amounts from "../../amounts";
|
import * as Amounts from "../../util/amounts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SenderWireInfos,
|
SenderWireInfos,
|
||||||
@ -35,7 +35,7 @@ import {
|
|||||||
|
|
||||||
import * as i18n from "../../i18n";
|
import * as i18n from "../../i18n";
|
||||||
|
|
||||||
import * as wire from "../../wire";
|
import * as wire from "../../util/wire";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBalance,
|
getBalance,
|
||||||
|
@ -23,7 +23,6 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ReactDOM from "react-dom";
|
import * as ReactDOM from "react-dom";
|
||||||
import URI = require("urijs");
|
|
||||||
|
|
||||||
import * as i18n from "../../i18n";
|
import * as i18n from "../../i18n";
|
||||||
|
|
||||||
@ -31,7 +30,7 @@ import { acceptTip, getReserveCreationInfo, getTipStatus } from "../wxApi";
|
|||||||
|
|
||||||
import { WithdrawDetailView, renderAmount, ProgressButton } from "../renderHtml";
|
import { WithdrawDetailView, renderAmount, ProgressButton } from "../renderHtml";
|
||||||
|
|
||||||
import * as Amounts from "../../amounts";
|
import * as Amounts from "../../util/amounts";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { TipStatus } from "../../walletTypes";
|
import { TipStatus } from "../../walletTypes";
|
||||||
|
|
||||||
@ -68,7 +67,7 @@ function TipDisplay(props: { talerTipUri: string }) {
|
|||||||
|
|
||||||
const accept = async () => {
|
const accept = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await acceptTip(props.talerTipUri);
|
await acceptTip(tipStatus.tipId);
|
||||||
setFinished(true);
|
setFinished(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -101,9 +100,8 @@ function TipDisplay(props: { talerTipUri: string }) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const url = new URI(document.location.href);
|
const url = new URL(document.location.href);
|
||||||
const query: any = URI.parseQuery(url.query());
|
const talerTipUri = url.searchParams.get("talerTipUri");
|
||||||
const talerTipUri = query.talerTipUri;
|
|
||||||
if (typeof talerTipUri !== "string") {
|
if (typeof talerTipUri !== "string") {
|
||||||
throw Error("talerTipUri must be a string");
|
throw Error("talerTipUri must be a string");
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,6 @@ import { WithdrawDetailView, renderAmount } from "../renderHtml";
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import * as ReactDOM from "react-dom";
|
import * as ReactDOM from "react-dom";
|
||||||
import URI = require("urijs");
|
|
||||||
import { getWithdrawDetails, acceptWithdrawal } from "../wxApi";
|
import { getWithdrawDetails, acceptWithdrawal } from "../wxApi";
|
||||||
|
|
||||||
function NewExchangeSelection(props: { talerWithdrawUri: string }) {
|
function NewExchangeSelection(props: { talerWithdrawUri: string }) {
|
||||||
@ -199,9 +198,8 @@ function NewExchangeSelection(props: { talerWithdrawUri: string }) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const url = new URI(document.location.href);
|
const url = new URL(document.location.href);
|
||||||
const query: any = URI.parseQuery(url.query());
|
const talerWithdrawUri = url.searchParams.get("talerWithdrawUri");
|
||||||
let talerWithdrawUri = query.talerWithdrawUri;
|
|
||||||
if (!talerWithdrawUri) {
|
if (!talerWithdrawUri) {
|
||||||
throw Error("withdraw URI required");
|
throw Error("withdraw URI required");
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,8 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import * as Amounts from "../amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import { DenominationRecord } from "../dbTypes";
|
import { DenominationRecord } from "../dbTypes";
|
||||||
import { ReserveCreationInfo } from "../walletTypes";
|
import { ReserveCreationInfo } from "../walletTypes";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
CurrencyRecord,
|
CurrencyRecord,
|
||||||
@ -173,14 +173,6 @@ export function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all planchets withdrawn from the given exchange.
|
|
||||||
*/
|
|
||||||
export function getPlanchets(exchangeBaseUrl: string): Promise<PlanchetRecord[]> {
|
|
||||||
return callBackend("get-planchets", { exchangeBaseUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all denoms offered by the given exchange.
|
* Get all denoms offered by the given exchange.
|
||||||
*/
|
*/
|
||||||
@ -211,13 +203,6 @@ export function confirmPay(proposalId: string, sessionId: string | undefined): P
|
|||||||
return callBackend("confirm-pay", { proposalId, sessionId });
|
return callBackend("confirm-pay", { proposalId, sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Replay paying for a purchase.
|
|
||||||
*/
|
|
||||||
export function submitPay(contractTermsHash: string, sessionId: string | undefined): Promise<ConfirmPayResult> {
|
|
||||||
return callBackend("submit-pay", { contractTermsHash, sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a reserve as confirmed.
|
* Mark a reserve as confirmed.
|
||||||
@ -302,14 +287,6 @@ export function clearNotification(): Promise<void> {
|
|||||||
return callBackend("clear-notification", { });
|
return callBackend("clear-notification", { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a contract.
|
|
||||||
*/
|
|
||||||
export function downloadProposal(url: string): Promise<number> {
|
|
||||||
return callBackend("download-proposal", { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a refund and accept it.
|
* Download a refund and accept it.
|
||||||
*/
|
*/
|
||||||
|
@ -23,8 +23,8 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { BrowserHttpLib } from "../http";
|
import { BrowserHttpLib } from "../util/http";
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
ConfirmReserveRequest,
|
ConfirmReserveRequest,
|
||||||
CreateReserveRequest,
|
CreateReserveRequest,
|
||||||
@ -39,11 +39,10 @@ import { openTalerDb, exportDb, importDb, deleteDb } from "../db";
|
|||||||
import { ChromeBadge } from "./chromeBadge";
|
import { ChromeBadge } from "./chromeBadge";
|
||||||
import { MessageType } from "./messages";
|
import { MessageType } from "./messages";
|
||||||
import * as wxApi from "./wxApi";
|
import * as wxApi from "./wxApi";
|
||||||
import URI = require("urijs");
|
|
||||||
import Port = chrome.runtime.Port;
|
import Port = chrome.runtime.Port;
|
||||||
import MessageSender = chrome.runtime.MessageSender;
|
import MessageSender = chrome.runtime.MessageSender;
|
||||||
import { BrowserCryptoWorkerFactory } from "../crypto/cryptoApi";
|
import { BrowserCryptoWorkerFactory } from "../crypto/cryptoApi";
|
||||||
import { OpenedPromise, openPromise } from "../promiseUtils";
|
import { OpenedPromise, openPromise } from "../util/promiseUtils";
|
||||||
|
|
||||||
const NeedsWallet = Symbol("NeedsWallet");
|
const NeedsWallet = Symbol("NeedsWallet");
|
||||||
|
|
||||||
@ -122,15 +121,6 @@ async function handleMessage(
|
|||||||
}
|
}
|
||||||
return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
|
return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
|
||||||
}
|
}
|
||||||
case "submit-pay": {
|
|
||||||
if (typeof detail.contractTermsHash !== "string") {
|
|
||||||
throw Error("contractTermsHash must be a string");
|
|
||||||
}
|
|
||||||
return needsWallet().submitPay(
|
|
||||||
detail.contractTermsHash,
|
|
||||||
detail.sessionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "exchange-info": {
|
case "exchange-info": {
|
||||||
if (!detail.baseUrl) {
|
if (!detail.baseUrl) {
|
||||||
return Promise.resolve({ error: "bad url" });
|
return Promise.resolve({ error: "bad url" });
|
||||||
@ -170,7 +160,7 @@ async function handleMessage(
|
|||||||
if (typeof detail.reservePub !== "string") {
|
if (typeof detail.reservePub !== "string") {
|
||||||
return Promise.reject(Error("reservePub missing"));
|
return Promise.reject(Error("reservePub missing"));
|
||||||
}
|
}
|
||||||
return needsWallet().withdrawPaybackReserve(detail.reservePub);
|
throw Error("not implemented");
|
||||||
}
|
}
|
||||||
case "get-coins": {
|
case "get-coins": {
|
||||||
if (typeof detail.exchangeBaseUrl !== "string") {
|
if (typeof detail.exchangeBaseUrl !== "string") {
|
||||||
@ -178,12 +168,6 @@ async function handleMessage(
|
|||||||
}
|
}
|
||||||
return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl);
|
return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl);
|
||||||
}
|
}
|
||||||
case "get-planchets": {
|
|
||||||
if (typeof detail.exchangeBaseUrl !== "string") {
|
|
||||||
return Promise.reject(Error("exchangBaseUrl missing"));
|
|
||||||
}
|
|
||||||
return needsWallet().getPlanchets(detail.exchangeBaseUrl);
|
|
||||||
}
|
|
||||||
case "get-denoms": {
|
case "get-denoms": {
|
||||||
if (typeof detail.exchangeBaseUrl !== "string") {
|
if (typeof detail.exchangeBaseUrl !== "string") {
|
||||||
return Promise.reject(Error("exchangBaseUrl missing"));
|
return Promise.reject(Error("exchangBaseUrl missing"));
|
||||||
@ -244,9 +228,6 @@ async function handleMessage(
|
|||||||
case "clear-notification": {
|
case "clear-notification": {
|
||||||
return needsWallet().clearNotification();
|
return needsWallet().clearNotification();
|
||||||
}
|
}
|
||||||
case "download-proposal": {
|
|
||||||
return needsWallet().downloadProposal(detail.url);
|
|
||||||
}
|
|
||||||
case "abort-failed-payment": {
|
case "abort-failed-payment": {
|
||||||
if (!detail.contractTermsHash) {
|
if (!detail.contractTermsHash) {
|
||||||
throw Error("contracTermsHash not given");
|
throw Error("contracTermsHash not given");
|
||||||
@ -404,18 +385,19 @@ function makeSyncWalletRedirect(
|
|||||||
oldUrl: string,
|
oldUrl: string,
|
||||||
params?: { [name: string]: string | undefined },
|
params?: { [name: string]: string | undefined },
|
||||||
): object {
|
): object {
|
||||||
const innerUrl = new URI(chrome.extension.getURL("/src/webex/pages/" + url));
|
const innerUrl = new URL(chrome.extension.getURL("/src/webex/pages/" + url));
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
if (params[key]) {
|
const p = params[key];
|
||||||
innerUrl.addSearch(key, params[key]);
|
if (p) {
|
||||||
|
innerUrl.searchParams.set(key, p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const outerUrl = new URI(
|
const outerUrl = new URL(
|
||||||
chrome.extension.getURL("/src/webex/pages/redirect.html"),
|
chrome.extension.getURL("/src/webex/pages/redirect.html"),
|
||||||
);
|
);
|
||||||
outerUrl.addSearch("url", innerUrl);
|
outerUrl.searchParams.set("url", innerUrl.href);
|
||||||
if (isFirefox()) {
|
if (isFirefox()) {
|
||||||
// Some platforms don't support the sync redirect (yet), so fall back to
|
// Some platforms don't support the sync redirect (yet), so fall back to
|
||||||
// async redirect after a timeout.
|
// async redirect after a timeout.
|
||||||
@ -423,12 +405,12 @@ function makeSyncWalletRedirect(
|
|||||||
await waitMs(150);
|
await waitMs(150);
|
||||||
const tab = await getTab(tabId);
|
const tab = await getTab(tabId);
|
||||||
if (tab.url === oldUrl) {
|
if (tab.url === oldUrl) {
|
||||||
chrome.tabs.update(tabId, { url: outerUrl.href() });
|
chrome.tabs.update(tabId, { url: outerUrl.href });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
doit();
|
doit();
|
||||||
}
|
}
|
||||||
return { redirectUrl: outerUrl.href() };
|
return { redirectUrl: outerUrl.href };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -549,29 +531,29 @@ export async function wxMain() {
|
|||||||
if (!tab.url || !tab.id) {
|
if (!tab.url || !tab.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const uri = new URI(tab.url);
|
const uri = new URL(tab.url);
|
||||||
if (uri.protocol() !== "http" && uri.protocol() !== "https") {
|
if (uri.protocol !== "http:" && uri.protocol !== "https:") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
"injecting into existing tab",
|
"injecting into existing tab",
|
||||||
tab.id,
|
tab.id,
|
||||||
"with url",
|
"with url",
|
||||||
uri.href(),
|
uri.href,
|
||||||
"protocol",
|
"protocol",
|
||||||
uri.protocol(),
|
uri.protocol,
|
||||||
);
|
);
|
||||||
injectScript(
|
injectScript(
|
||||||
tab.id,
|
tab.id,
|
||||||
{ file: "/dist/contentScript-bundle.js", runAt: "document_start" },
|
{ file: "/dist/contentScript-bundle.js", runAt: "document_start" },
|
||||||
uri.href(),
|
uri.href,
|
||||||
);
|
);
|
||||||
const code = `
|
const code = `
|
||||||
if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) {
|
if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) {
|
||||||
document.dispatchEvent(new Event("taler-probe-result"));
|
document.dispatchEvent(new Event("taler-probe-result"));
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
injectScript(tab.id, { code, runAt: "document_start" }, uri.href());
|
injectScript(tab.id, { code, runAt: "document_start" }, uri.href);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -603,8 +585,8 @@ export async function wxMain() {
|
|||||||
if (!tab.url || !tab.id) {
|
if (!tab.url || !tab.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uri = new URI(tab.url);
|
const uri = new URL(tab.url);
|
||||||
if (!(uri.protocol() === "http" || uri.protocol() === "https")) {
|
if (!(uri.protocol === "http:" || uri.protocol === "https:")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const code = `
|
const code = `
|
||||||
@ -612,7 +594,7 @@ export async function wxMain() {
|
|||||||
document.dispatchEvent(new Event("taler-probe-result"));
|
document.dispatchEvent(new Event("taler-probe-result"));
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
injectScript(tab.id!, { code, runAt: "document_start" }, uri.href());
|
injectScript(tab.id!, { code, runAt: "document_start" }, uri.href);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -23,9 +23,7 @@
|
|||||||
"esModuleInterop": true
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/amounts.ts",
|
|
||||||
"src/android/index.ts",
|
"src/android/index.ts",
|
||||||
"src/checkable.ts",
|
|
||||||
"src/crypto/browserWorkerEntry.ts",
|
"src/crypto/browserWorkerEntry.ts",
|
||||||
"src/crypto/cryptoApi.ts",
|
"src/crypto/cryptoApi.ts",
|
||||||
"src/crypto/cryptoImplementation.ts",
|
"src/crypto/cryptoImplementation.ts",
|
||||||
@ -46,22 +44,42 @@
|
|||||||
"src/headless/integrationtest.ts",
|
"src/headless/integrationtest.ts",
|
||||||
"src/headless/merchant.ts",
|
"src/headless/merchant.ts",
|
||||||
"src/headless/taler-wallet-cli.ts",
|
"src/headless/taler-wallet-cli.ts",
|
||||||
"src/helpers-test.ts",
|
|
||||||
"src/helpers.ts",
|
|
||||||
"src/http.ts",
|
|
||||||
"src/i18n.tsx",
|
"src/i18n.tsx",
|
||||||
"src/i18n/strings.ts",
|
"src/i18n/strings.ts",
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/libtoolVersion-test.ts",
|
|
||||||
"src/libtoolVersion.ts",
|
|
||||||
"src/logging.ts",
|
|
||||||
"src/promiseUtils.ts",
|
|
||||||
"src/query.ts",
|
|
||||||
"src/talerTypes.ts",
|
"src/talerTypes.ts",
|
||||||
"src/taleruri-test.ts",
|
|
||||||
"src/taleruri.ts",
|
|
||||||
"src/timer.ts",
|
|
||||||
"src/types-test.ts",
|
"src/types-test.ts",
|
||||||
|
"src/util/amounts.ts",
|
||||||
|
"src/util/assertUnreachable.ts",
|
||||||
|
"src/util/asyncMemo.ts",
|
||||||
|
"src/util/checkable.ts",
|
||||||
|
"src/util/helpers-test.ts",
|
||||||
|
"src/util/helpers.ts",
|
||||||
|
"src/util/http.ts",
|
||||||
|
"src/util/libtoolVersion-test.ts",
|
||||||
|
"src/util/libtoolVersion.ts",
|
||||||
|
"src/util/logging.ts",
|
||||||
|
"src/util/payto-test.ts",
|
||||||
|
"src/util/payto.ts",
|
||||||
|
"src/util/promiseUtils.ts",
|
||||||
|
"src/util/query.ts",
|
||||||
|
"src/util/taleruri-test.ts",
|
||||||
|
"src/util/taleruri.ts",
|
||||||
|
"src/util/timer.ts",
|
||||||
|
"src/util/wire.ts",
|
||||||
|
"src/wallet-impl/balance.ts",
|
||||||
|
"src/wallet-impl/exchanges.ts",
|
||||||
|
"src/wallet-impl/history.ts",
|
||||||
|
"src/wallet-impl/pay.ts",
|
||||||
|
"src/wallet-impl/payback.ts",
|
||||||
|
"src/wallet-impl/pending.ts",
|
||||||
|
"src/wallet-impl/refresh.ts",
|
||||||
|
"src/wallet-impl/refund.ts",
|
||||||
|
"src/wallet-impl/reserves.ts",
|
||||||
|
"src/wallet-impl/return.ts",
|
||||||
|
"src/wallet-impl/state.ts",
|
||||||
|
"src/wallet-impl/tip.ts",
|
||||||
|
"src/wallet-impl/withdraw.ts",
|
||||||
"src/wallet-test.ts",
|
"src/wallet-test.ts",
|
||||||
"src/wallet.ts",
|
"src/wallet.ts",
|
||||||
"src/walletTypes.ts",
|
"src/walletTypes.ts",
|
||||||
@ -86,7 +104,6 @@
|
|||||||
"src/webex/pages/withdraw.tsx",
|
"src/webex/pages/withdraw.tsx",
|
||||||
"src/webex/renderHtml.tsx",
|
"src/webex/renderHtml.tsx",
|
||||||
"src/webex/wxApi.ts",
|
"src/webex/wxApi.ts",
|
||||||
"src/webex/wxBackend.ts",
|
"src/webex/wxBackend.ts"
|
||||||
"src/wire.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
10
yarn.lock
10
yarn.lock
@ -394,11 +394,6 @@
|
|||||||
"@types/prop-types" "*"
|
"@types/prop-types" "*"
|
||||||
csstype "^2.2.0"
|
csstype "^2.2.0"
|
||||||
|
|
||||||
"@types/urijs@^1.19.3":
|
|
||||||
version "1.19.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.4.tgz#29c4a694d4842d7f95e359a26223fc1865f1ab13"
|
|
||||||
integrity sha512-uHUvuLfy4YkRHL4UH8J8oRsINhdEHd9ymag7KJZVT94CjAmY1njoUzhazJsZjwfy+IpWKQKGVyXCwzhZvg73Fg==
|
|
||||||
|
|
||||||
"@webassemblyjs/ast@1.8.5":
|
"@webassemblyjs/ast@1.8.5":
|
||||||
version "1.8.5"
|
version "1.8.5"
|
||||||
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
|
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
|
||||||
@ -7084,11 +7079,6 @@ uri-js@^4.2.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
punycode "^2.1.0"
|
||||||
|
|
||||||
urijs@^1.18.10:
|
|
||||||
version "1.19.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
|
|
||||||
integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
|
|
||||||
|
|
||||||
urix@^0.1.0:
|
urix@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
|
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
|
||||||
|
Loading…
Reference in New Issue
Block a user