wallet robustness WIP
This commit is contained in:
parent
809fa18644
commit
aaf7e1338d
@ -60,8 +60,6 @@ const paths = {
|
|||||||
dist: [
|
dist: [
|
||||||
"dist/*-bundle.js",
|
"dist/*-bundle.js",
|
||||||
"dist/*-bundle.js.map",
|
"dist/*-bundle.js.map",
|
||||||
"emscripten/taler-emscripten-lib.js",
|
|
||||||
"emscripten/taler-emscripten-lib.wasm",
|
|
||||||
"img/icon.png",
|
"img/icon.png",
|
||||||
"img/logo.png",
|
"img/logo.png",
|
||||||
"src/webex/**/*.{js,css,html}",
|
"src/webex/**/*.{js,css,html}",
|
||||||
@ -149,7 +147,7 @@ function dist_prod() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compile_prod(callback) {
|
function compile_prod(callback) {
|
||||||
let config = require("./webpack.config.js")({ prod: true });
|
let config = require("./webpack.config.js")({ mode: "production" });
|
||||||
webpack(config, function(err, stats) {
|
webpack(config, function(err, stats) {
|
||||||
if (err) {
|
if (err) {
|
||||||
throw new gutil.PluginError("webpack", err);
|
throw new gutil.PluginError("webpack", err);
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
"@types/urijs": "^1.19.3",
|
"@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.14",
|
"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"
|
"urijs": "^1.18.10"
|
||||||
|
@ -27,7 +27,7 @@ import { AmountJson } from "../amounts";
|
|||||||
import {
|
import {
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
PreCoinRecord,
|
PlanchetRecord,
|
||||||
RefreshSessionRecord,
|
RefreshSessionRecord,
|
||||||
ReserveRecord,
|
ReserveRecord,
|
||||||
TipPlanchet,
|
TipPlanchet,
|
||||||
@ -38,7 +38,7 @@ import { CryptoWorker } from "./cryptoWorker";
|
|||||||
|
|
||||||
import { ContractTerms, PaybackRequest } from "../talerTypes";
|
import { ContractTerms, PaybackRequest } from "../talerTypes";
|
||||||
|
|
||||||
import { BenchmarkResult, CoinWithDenom, PayCoinInfo } from "../walletTypes";
|
import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult } from "../walletTypes";
|
||||||
|
|
||||||
import * as timer from "../timer";
|
import * as timer from "../timer";
|
||||||
|
|
||||||
@ -173,6 +173,7 @@ export class CryptoApi {
|
|||||||
*/
|
*/
|
||||||
wake(ws: WorkerState, work: WorkItem): void {
|
wake(ws: WorkerState, work: WorkItem): void {
|
||||||
if (this.stopped) {
|
if (this.stopped) {
|
||||||
|
console.log("cryptoApi is stopped");
|
||||||
CryptoApi.enableTracing && console.log("not waking, as cryptoApi is stopped");
|
CryptoApi.enableTracing && console.log("not waking, as cryptoApi is stopped");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -299,7 +300,6 @@ export class CryptoApi {
|
|||||||
priority: number,
|
priority: number,
|
||||||
...args: any[]
|
...args: any[]
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
CryptoApi.enableTracing && console.log("cryptoApi: doRpc called");
|
|
||||||
const p: Promise<T> = new Promise<T>((resolve, reject) => {
|
const p: Promise<T> = new Promise<T>((resolve, reject) => {
|
||||||
const rpcId = this.nextRpcId++;
|
const rpcId = this.nextRpcId++;
|
||||||
const workItem: WorkItem = {
|
const workItem: WorkItem = {
|
||||||
@ -332,16 +332,14 @@ export class CryptoApi {
|
|||||||
throw Error("assertion failed");
|
throw Error("assertion failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
return p.then((r: T) => {
|
return p;
|
||||||
return r;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createPreCoin(
|
createPlanchet(
|
||||||
denom: DenominationRecord,
|
denom: DenominationRecord,
|
||||||
reserve: ReserveRecord,
|
reserve: ReserveRecord,
|
||||||
): Promise<PreCoinRecord> {
|
): Promise<PlanchetCreationResult> {
|
||||||
return this.doRpc<PreCoinRecord>("createPreCoin", 1, denom, reserve);
|
return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, denom, reserve);
|
||||||
}
|
}
|
||||||
|
|
||||||
createTipPlanchet(denom: DenominationRecord): Promise<TipPlanchet> {
|
createTipPlanchet(denom: DenominationRecord): Promise<TipPlanchet> {
|
||||||
|
@ -28,8 +28,7 @@ import {
|
|||||||
CoinRecord,
|
CoinRecord,
|
||||||
CoinStatus,
|
CoinStatus,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
PreCoinRecord,
|
RefreshPlanchetRecord,
|
||||||
RefreshPreCoinRecord,
|
|
||||||
RefreshSessionRecord,
|
RefreshSessionRecord,
|
||||||
ReserveRecord,
|
ReserveRecord,
|
||||||
TipPlanchet,
|
TipPlanchet,
|
||||||
@ -42,6 +41,7 @@ import {
|
|||||||
CoinWithDenom,
|
CoinWithDenom,
|
||||||
PayCoinInfo,
|
PayCoinInfo,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
|
PlanchetCreationResult,
|
||||||
} from "../walletTypes";
|
} from "../walletTypes";
|
||||||
import { canonicalJson, getTalerStampSec } from "../helpers";
|
import { canonicalJson, getTalerStampSec } from "../helpers";
|
||||||
import { AmountJson } from "../amounts";
|
import { AmountJson } from "../amounts";
|
||||||
@ -154,10 +154,10 @@ export class CryptoImplementation {
|
|||||||
* Create a pre-coin of the given denomination to be withdrawn from then given
|
* Create a pre-coin of the given denomination to be withdrawn from then given
|
||||||
* reserve.
|
* reserve.
|
||||||
*/
|
*/
|
||||||
createPreCoin(
|
createPlanchet(
|
||||||
denom: DenominationRecord,
|
denom: DenominationRecord,
|
||||||
reserve: ReserveRecord,
|
reserve: ReserveRecord,
|
||||||
): PreCoinRecord {
|
): PlanchetCreationResult {
|
||||||
const reservePub = decodeCrock(reserve.reservePub);
|
const reservePub = decodeCrock(reserve.reservePub);
|
||||||
const reservePriv = decodeCrock(reserve.reservePriv);
|
const reservePriv = decodeCrock(reserve.reservePriv);
|
||||||
const denomPub = decodeCrock(denom.denomPub);
|
const denomPub = decodeCrock(denom.denomPub);
|
||||||
@ -179,7 +179,7 @@ export class CryptoImplementation {
|
|||||||
|
|
||||||
const sig = eddsaSign(withdrawRequest, reservePriv);
|
const sig = eddsaSign(withdrawRequest, reservePriv);
|
||||||
|
|
||||||
const preCoin: PreCoinRecord = {
|
const planchet: PlanchetCreationResult = {
|
||||||
blindingKey: encodeCrock(blindingFactor),
|
blindingKey: encodeCrock(blindingFactor),
|
||||||
coinEv: encodeCrock(ev),
|
coinEv: encodeCrock(ev),
|
||||||
coinPriv: encodeCrock(coinKeyPair.eddsaPriv),
|
coinPriv: encodeCrock(coinKeyPair.eddsaPriv),
|
||||||
@ -188,11 +188,10 @@ export class CryptoImplementation {
|
|||||||
denomPub: encodeCrock(denomPub),
|
denomPub: encodeCrock(denomPub),
|
||||||
denomPubHash: encodeCrock(denomPubHash),
|
denomPubHash: encodeCrock(denomPubHash),
|
||||||
exchangeBaseUrl: reserve.exchangeBaseUrl,
|
exchangeBaseUrl: reserve.exchangeBaseUrl,
|
||||||
isFromTip: false,
|
|
||||||
reservePub: encodeCrock(reservePub),
|
reservePub: encodeCrock(reservePub),
|
||||||
withdrawSig: encodeCrock(sig),
|
withdrawSig: encodeCrock(sig),
|
||||||
};
|
};
|
||||||
return preCoin;
|
return planchet;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -424,7 +423,7 @@ export class CryptoImplementation {
|
|||||||
const transferPubs: string[] = [];
|
const transferPubs: string[] = [];
|
||||||
const transferPrivs: string[] = [];
|
const transferPrivs: string[] = [];
|
||||||
|
|
||||||
const preCoinsForGammas: RefreshPreCoinRecord[][] = [];
|
const planchetsForGammas: RefreshPlanchetRecord[][] = [];
|
||||||
|
|
||||||
for (let i = 0; i < kappa; i++) {
|
for (let i = 0; i < kappa; i++) {
|
||||||
const transferKeyPair = createEcdheKeyPair();
|
const transferKeyPair = createEcdheKeyPair();
|
||||||
@ -442,7 +441,7 @@ export class CryptoImplementation {
|
|||||||
sessionHc.update(amountToBuffer(valueWithFee));
|
sessionHc.update(amountToBuffer(valueWithFee));
|
||||||
|
|
||||||
for (let i = 0; i < kappa; i++) {
|
for (let i = 0; i < kappa; i++) {
|
||||||
const preCoins: RefreshPreCoinRecord[] = [];
|
const planchets: RefreshPlanchetRecord[] = [];
|
||||||
for (let j = 0; j < newCoinDenoms.length; j++) {
|
for (let j = 0; j < newCoinDenoms.length; j++) {
|
||||||
const transferPriv = decodeCrock(transferPrivs[i]);
|
const transferPriv = decodeCrock(transferPrivs[i]);
|
||||||
const oldCoinPub = decodeCrock(meltCoin.coinPub);
|
const oldCoinPub = decodeCrock(meltCoin.coinPub);
|
||||||
@ -456,16 +455,16 @@ export class CryptoImplementation {
|
|||||||
const pubHash = hash(coinPub);
|
const pubHash = hash(coinPub);
|
||||||
const denomPub = decodeCrock(newCoinDenoms[j].denomPub);
|
const denomPub = decodeCrock(newCoinDenoms[j].denomPub);
|
||||||
const ev = rsaBlind(pubHash, blindingFactor, denomPub);
|
const ev = rsaBlind(pubHash, blindingFactor, denomPub);
|
||||||
const preCoin: RefreshPreCoinRecord = {
|
const planchet: RefreshPlanchetRecord = {
|
||||||
blindingKey: encodeCrock(blindingFactor),
|
blindingKey: encodeCrock(blindingFactor),
|
||||||
coinEv: encodeCrock(ev),
|
coinEv: encodeCrock(ev),
|
||||||
privateKey: encodeCrock(coinPriv),
|
privateKey: encodeCrock(coinPriv),
|
||||||
publicKey: encodeCrock(coinPub),
|
publicKey: encodeCrock(coinPub),
|
||||||
};
|
};
|
||||||
preCoins.push(preCoin);
|
planchets.push(planchet);
|
||||||
sessionHc.update(ev);
|
sessionHc.update(ev);
|
||||||
}
|
}
|
||||||
preCoinsForGammas.push(preCoins);
|
planchetsForGammas.push(planchets);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionHash = sessionHc.finish();
|
const sessionHash = sessionHc.finish();
|
||||||
@ -496,7 +495,7 @@ export class CryptoImplementation {
|
|||||||
newDenomHashes: newCoinDenoms.map(d => d.denomPubHash),
|
newDenomHashes: newCoinDenoms.map(d => d.denomPubHash),
|
||||||
newDenoms: newCoinDenoms.map(d => d.denomPub),
|
newDenoms: newCoinDenoms.map(d => d.denomPub),
|
||||||
norevealIndex: undefined,
|
norevealIndex: undefined,
|
||||||
preCoinsForGammas,
|
planchetsForGammas: planchetsForGammas,
|
||||||
transferPrivs,
|
transferPrivs,
|
||||||
transferPubs,
|
transferPubs,
|
||||||
valueOutput,
|
valueOutput,
|
||||||
|
@ -88,5 +88,5 @@ export function kdf(
|
|||||||
output.set(chunk, i * 32);
|
output.set(chunk, i * 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output.slice(0, outputLength);
|
||||||
}
|
}
|
||||||
|
@ -237,6 +237,9 @@ function rsaFullDomainHash(hm: Uint8Array, rsaPub: RsaPub): bigint.BigInteger {
|
|||||||
function rsaPubDecode(rsaPub: Uint8Array): RsaPub {
|
function rsaPubDecode(rsaPub: Uint8Array): RsaPub {
|
||||||
const modulusLength = (rsaPub[0] << 8) | rsaPub[1];
|
const modulusLength = (rsaPub[0] << 8) | rsaPub[1];
|
||||||
const exponentLength = (rsaPub[2] << 8) | rsaPub[3];
|
const exponentLength = (rsaPub[2] << 8) | rsaPub[3];
|
||||||
|
if (4 + exponentLength + modulusLength != rsaPub.length) {
|
||||||
|
throw Error("invalid RSA public key (format wrong)");
|
||||||
|
}
|
||||||
const modulus = rsaPub.slice(4, 4 + modulusLength);
|
const modulus = rsaPub.slice(4, 4 + modulusLength);
|
||||||
const exponent = rsaPub.slice(
|
const exponent = rsaPub.slice(
|
||||||
4 + modulusLength,
|
4 + modulusLength,
|
||||||
|
164
src/dbTypes.ts
164
src/dbTypes.ts
@ -57,6 +57,13 @@ export enum ReserveRecordStatus {
|
|||||||
*/
|
*/
|
||||||
REGISTERING_BANK = "registering-bank",
|
REGISTERING_BANK = "registering-bank",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We've registered reserve's information with the bank
|
||||||
|
* and are now waiting for the user to confirm the withdraw
|
||||||
|
* with the bank (typically 2nd factor auth).
|
||||||
|
*/
|
||||||
|
WAIT_CONFIRM_BANK = "wait-confirm-bank",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Querying reserve status with the exchange.
|
* Querying reserve status with the exchange.
|
||||||
*/
|
*/
|
||||||
@ -117,22 +124,26 @@ export interface ReserveRecord {
|
|||||||
timestampConfirmed: Timestamp | undefined;
|
timestampConfirmed: Timestamp | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current amount left in the reserve
|
* Amount that's still available for withdrawing
|
||||||
|
* from this reserve.
|
||||||
*/
|
*/
|
||||||
currentAmount: AmountJson | null;
|
withdrawRemainingAmount: AmountJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount allocated for withdrawing.
|
||||||
|
* The corresponding withdraw operation may or may not
|
||||||
|
* have been completed yet.
|
||||||
|
*/
|
||||||
|
withdrawAllocatedAmount: AmountJson;
|
||||||
|
|
||||||
|
withdrawCompletedAmount: AmountJson;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Amount requested when the reserve was created.
|
* Amount requested when the reserve was created.
|
||||||
* When a reserve is re-used (rare!) the current_amount can
|
* When a reserve is re-used (rare!) the current_amount can
|
||||||
* be higher than the requested_amount
|
* be higher than the requested_amount
|
||||||
*/
|
*/
|
||||||
requestedAmount: AmountJson;
|
initiallyRequestedAmount: AmountJson;
|
||||||
|
|
||||||
/**
|
|
||||||
* What's the current amount that sits
|
|
||||||
* in precoins?
|
|
||||||
*/
|
|
||||||
precoinAmount: AmountJson;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We got some payback to this reserve. We'll cease to automatically
|
* We got some payback to this reserve. We'll cease to automatically
|
||||||
@ -154,8 +165,19 @@ export interface ReserveRecord {
|
|||||||
|
|
||||||
bankWithdrawStatusUrl?: string;
|
bankWithdrawStatusUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL that the bank gave us to redirect the customer
|
||||||
|
* to in order to confirm a withdrawal.
|
||||||
|
*/
|
||||||
|
bankWithdrawConfirmUrl?: string;
|
||||||
|
|
||||||
reserveStatus: ReserveRecordStatus;
|
reserveStatus: ReserveRecordStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time of the last successful status query.
|
||||||
|
*/
|
||||||
|
lastStatusQuery: Timestamp | undefined;
|
||||||
|
|
||||||
lastError?: OperationError;
|
lastError?: OperationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,7 +443,16 @@ 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 PreCoinRecord {
|
export interface PlanchetRecord {
|
||||||
|
withdrawSessionId: string;
|
||||||
|
/**
|
||||||
|
* Index of the coin in the withdrawal session.
|
||||||
|
*/
|
||||||
|
coinIndex: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public key of the coin.
|
||||||
|
*/
|
||||||
coinPub: string;
|
coinPub: string;
|
||||||
coinPriv: string;
|
coinPriv: string;
|
||||||
reservePub: string;
|
reservePub: string;
|
||||||
@ -443,7 +474,7 @@ export interface PreCoinRecord {
|
|||||||
/**
|
/**
|
||||||
* Planchet for a coin during refrehs.
|
* Planchet for a coin during refrehs.
|
||||||
*/
|
*/
|
||||||
export interface RefreshPreCoinRecord {
|
export interface RefreshPlanchetRecord {
|
||||||
/**
|
/**
|
||||||
* Public key for the coin.
|
* Public key for the coin.
|
||||||
*/
|
*/
|
||||||
@ -485,6 +516,16 @@ export enum CoinStatus {
|
|||||||
* of the wallet database.
|
* of the wallet database.
|
||||||
*/
|
*/
|
||||||
export interface CoinRecord {
|
export interface CoinRecord {
|
||||||
|
/**
|
||||||
|
* Withdraw session ID, or "" (empty string) if withdrawn via refresh.
|
||||||
|
*/
|
||||||
|
withdrawSessionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the coin in the withdrawal session.
|
||||||
|
*/
|
||||||
|
coinIndex: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public key of the coin.
|
* Public key of the coin.
|
||||||
*/
|
*/
|
||||||
@ -546,11 +587,17 @@ export interface CoinRecord {
|
|||||||
status: CoinStatus;
|
status: CoinStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProposalStatus {
|
||||||
|
PROPOSED = "proposed",
|
||||||
|
ACCEPTED = "accepted",
|
||||||
|
REJECTED = "rejected",
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proposal record, stored in the wallet's database.
|
* Record for a downloaded order, stored in the wallet's database.
|
||||||
*/
|
*/
|
||||||
@Checkable.Class()
|
@Checkable.Class()
|
||||||
export class ProposalDownloadRecord {
|
export class ProposalRecord {
|
||||||
/**
|
/**
|
||||||
* URL where the proposal was downloaded.
|
* URL where the proposal was downloaded.
|
||||||
*/
|
*/
|
||||||
@ -576,10 +623,10 @@ export class ProposalDownloadRecord {
|
|||||||
contractTermsHash: string;
|
contractTermsHash: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serial ID when the offer is stored in the wallet DB.
|
* Unique ID when the order is stored in the wallet DB.
|
||||||
*/
|
*/
|
||||||
@Checkable.Optional(Checkable.Number())
|
@Checkable.String()
|
||||||
id?: number;
|
proposalId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp (in ms) of when the record
|
* Timestamp (in ms) of when the record
|
||||||
@ -594,6 +641,9 @@ export class ProposalDownloadRecord {
|
|||||||
@Checkable.String()
|
@Checkable.String()
|
||||||
noncePriv: string;
|
noncePriv: string;
|
||||||
|
|
||||||
|
@Checkable.String()
|
||||||
|
proposalStatus: ProposalStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session ID we got when downloading the contract.
|
* Session ID we got when downloading the contract.
|
||||||
*/
|
*/
|
||||||
@ -604,7 +654,7 @@ export class ProposalDownloadRecord {
|
|||||||
* Verify that a value matches the schema of this class and convert it into a
|
* Verify that a value matches the schema of this class and convert it into a
|
||||||
* member.
|
* member.
|
||||||
*/
|
*/
|
||||||
static checked: (obj: any) => ProposalDownloadRecord;
|
static checked: (obj: any) => ProposalRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -717,9 +767,9 @@ export interface RefreshSessionRecord {
|
|||||||
newDenoms: string[];
|
newDenoms: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Precoins for each cut-and-choose instance.
|
* Planchets for each cut-and-choose instance.
|
||||||
*/
|
*/
|
||||||
preCoinsForGammas: RefreshPreCoinRecord[][];
|
planchetsForGammas: RefreshPlanchetRecord[][];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The transfer keys, kappa of them.
|
* The transfer keys, kappa of them.
|
||||||
@ -933,7 +983,9 @@ export interface CoinsReturnRecord {
|
|||||||
wire: any;
|
wire: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithdrawalRecord {
|
export interface WithdrawalSessionRecord {
|
||||||
|
withdrawSessionId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve that we're withdrawing from.
|
* Reserve that we're withdrawing from.
|
||||||
*/
|
*/
|
||||||
@ -956,9 +1008,29 @@ export interface WithdrawalRecord {
|
|||||||
*/
|
*/
|
||||||
withdrawalAmount: string;
|
withdrawalAmount: string;
|
||||||
|
|
||||||
numCoinsTotal: number;
|
denoms: string[];
|
||||||
|
|
||||||
numCoinsWithdrawn: number;
|
/**
|
||||||
|
* Coins in this session that are withdrawn are set to true.
|
||||||
|
*/
|
||||||
|
withdrawn: boolean[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coins in this session already have a planchet are set to true.
|
||||||
|
*/
|
||||||
|
planchetCreated: boolean[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankWithdrawUriRecord {
|
||||||
|
/**
|
||||||
|
* The withdraw URI we got from the bank.
|
||||||
|
*/
|
||||||
|
talerWithdrawUri: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve that was created for the withdraw URI.
|
||||||
|
*/
|
||||||
|
reservePub: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tslint:disable:completed-docs */
|
/* tslint:disable:completed-docs */
|
||||||
@ -967,7 +1039,7 @@ export interface WithdrawalRecord {
|
|||||||
* The stores and indices for the wallet database.
|
* The stores and indices for the wallet database.
|
||||||
*/
|
*/
|
||||||
export namespace Stores {
|
export namespace Stores {
|
||||||
class ExchangeStore extends Store<ExchangeRecord> {
|
class ExchangesStore extends Store<ExchangeRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("exchanges", { keyPath: "baseUrl" });
|
super("exchanges", { keyPath: "baseUrl" });
|
||||||
}
|
}
|
||||||
@ -988,16 +1060,18 @@ export namespace Stores {
|
|||||||
"denomPubIndex",
|
"denomPubIndex",
|
||||||
"denomPub",
|
"denomPub",
|
||||||
);
|
);
|
||||||
|
byWithdrawalWithIdx = new Index<any, CoinRecord>(
|
||||||
|
this,
|
||||||
|
"planchetsByWithdrawalWithIdxIndex",
|
||||||
|
["withdrawSessionId", "coinIndex"],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProposalsStore extends Store<ProposalDownloadRecord> {
|
class ProposalsStore extends Store<ProposalRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("proposals", {
|
super("proposals", { keyPath: "proposalId" });
|
||||||
autoIncrement: true,
|
|
||||||
keyPath: "id",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
urlIndex = new Index<string, ProposalDownloadRecord>(
|
urlIndex = new Index<string, ProposalRecord>(
|
||||||
this,
|
this,
|
||||||
"urlIndex",
|
"urlIndex",
|
||||||
"url",
|
"url",
|
||||||
@ -1084,28 +1158,39 @@ export namespace Stores {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class WithdrawalsStore extends Store<WithdrawalRecord> {
|
class WithdrawalSessionsStore extends Store<WithdrawalSessionRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("withdrawals", { keyPath: "id", autoIncrement: true });
|
super("withdrawals", { keyPath: "withdrawSessionId" });
|
||||||
}
|
}
|
||||||
byReservePub = new Index<string, WithdrawalRecord>(
|
byReservePub = new Index<string, WithdrawalSessionRecord>(
|
||||||
this,
|
this,
|
||||||
"withdrawalsReservePubIndex",
|
"withdrawalsReservePubIndex",
|
||||||
"reservePub",
|
"reservePub",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PreCoinsStore extends Store<PreCoinRecord> {
|
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("precoins", {
|
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlanchetsStore extends Store<PlanchetRecord> {
|
||||||
|
constructor() {
|
||||||
|
super("planchets", {
|
||||||
keyPath: "coinPub",
|
keyPath: "coinPub",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
byReservePub = new Index<string, PreCoinRecord>(
|
byReservePub = new Index<string, PlanchetRecord>(
|
||||||
this,
|
this,
|
||||||
"precoinsReservePubIndex",
|
"planchetsReservePubIndex",
|
||||||
"reservePub",
|
"reservePub",
|
||||||
);
|
);
|
||||||
|
byWithdrawalWithIdx = new Index<any, PlanchetRecord>(
|
||||||
|
this,
|
||||||
|
"planchetsByWithdrawalWithIdxIndex",
|
||||||
|
["withdrawSessionId", "coinIndex"],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const coins = new CoinsStore();
|
export const coins = new CoinsStore();
|
||||||
@ -1115,8 +1200,8 @@ export namespace Stores {
|
|||||||
export const config = new ConfigStore();
|
export const config = new ConfigStore();
|
||||||
export const currencies = new CurrenciesStore();
|
export const currencies = new CurrenciesStore();
|
||||||
export const denominations = new DenominationsStore();
|
export const denominations = new DenominationsStore();
|
||||||
export const exchanges = new ExchangeStore();
|
export const exchanges = new ExchangesStore();
|
||||||
export const precoins = new PreCoinsStore();
|
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",
|
||||||
@ -1125,7 +1210,8 @@ export namespace Stores {
|
|||||||
export const purchases = new PurchasesStore();
|
export const purchases = new PurchasesStore();
|
||||||
export const tips = new TipsStore();
|
export const tips = new TipsStore();
|
||||||
export const senderWires = new SenderWiresStore();
|
export const senderWires = new SenderWiresStore();
|
||||||
export const withdrawals = new WithdrawalsStore();
|
export const withdrawalSession = new WithdrawalSessionsStore();
|
||||||
|
export const bankWithdrawUris = new BankWithdrawUrisStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tslint:enable:completed-docs */
|
/* tslint:enable:completed-docs */
|
||||||
|
@ -45,6 +45,37 @@ function makeId(length: number): string {
|
|||||||
export class Bank {
|
export class Bank {
|
||||||
constructor(private bankBaseUrl: string) {}
|
constructor(private bankBaseUrl: string) {}
|
||||||
|
|
||||||
|
async generateWithdrawUri(bankUser: BankUser, amount: string): Promise<string> {
|
||||||
|
const body = {
|
||||||
|
amount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqUrl = new URI("api/withdraw-headless-uri")
|
||||||
|
.absoluteTo(this.bankBaseUrl)
|
||||||
|
.href();
|
||||||
|
|
||||||
|
const resp = await Axios({
|
||||||
|
method: "post",
|
||||||
|
url: reqUrl,
|
||||||
|
data: body,
|
||||||
|
responseType: "json",
|
||||||
|
headers: {
|
||||||
|
"X-Taler-Bank-Username": bankUser.username,
|
||||||
|
"X-Taler-Bank-Password": bankUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status != 200) {
|
||||||
|
throw Error("failed to create bank reserve");
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawUri = resp.data["taler_withdraw_uri"];
|
||||||
|
if (!withdrawUri) {
|
||||||
|
throw Error("Bank's response did not include withdraw URI");
|
||||||
|
}
|
||||||
|
return withdrawUri;
|
||||||
|
}
|
||||||
|
|
||||||
async createReserve(
|
async createReserve(
|
||||||
bankUser: BankUser,
|
bankUser: BankUser,
|
||||||
amount: string,
|
amount: string,
|
||||||
|
@ -29,6 +29,7 @@ export let STRING: Converter<string> = new Converter<string>();
|
|||||||
export interface OptionArgs<T> {
|
export interface OptionArgs<T> {
|
||||||
help?: string;
|
help?: string;
|
||||||
default?: T;
|
default?: T;
|
||||||
|
onPresentHandler?: (v: T) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArgumentArgs<T> {
|
export interface ArgumentArgs<T> {
|
||||||
@ -269,9 +270,6 @@ export class CommandGroup<GN extends keyof any, TG> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printHelp(progName: string, parents: CommandGroup<any, any>[]) {
|
printHelp(progName: string, parents: CommandGroup<any, any>[]) {
|
||||||
const chain: CommandGroup<any, any>[] = Array.prototype.concat(parents, [
|
|
||||||
this,
|
|
||||||
]);
|
|
||||||
let usageSpec = "";
|
let usageSpec = "";
|
||||||
for (let p of parents) {
|
for (let p of parents) {
|
||||||
usageSpec += (p.name ?? progName) + " ";
|
usageSpec += (p.name ?? progName) + " ";
|
||||||
@ -352,6 +350,7 @@ export class CommandGroup<GN extends keyof any, TG> {
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
throw Error("not reached");
|
throw Error("not reached");
|
||||||
}
|
}
|
||||||
|
foundOptions[d.name] = true;
|
||||||
myArgs[d.name] = true;
|
myArgs[d.name] = true;
|
||||||
} else {
|
} else {
|
||||||
if (r.value === undefined) {
|
if (r.value === undefined) {
|
||||||
@ -380,6 +379,7 @@ export class CommandGroup<GN extends keyof any, TG> {
|
|||||||
}
|
}
|
||||||
if (opt.isFlag) {
|
if (opt.isFlag) {
|
||||||
myArgs[opt.name] = true;
|
myArgs[opt.name] = true;
|
||||||
|
foundOptions[opt.name] = true;
|
||||||
} else {
|
} else {
|
||||||
if (si == optShort.length - 1) {
|
if (si == optShort.length - 1) {
|
||||||
if (i === unparsedArgs.length - 1) {
|
if (i === unparsedArgs.length - 1) {
|
||||||
@ -449,6 +449,13 @@ export class CommandGroup<GN extends keyof any, TG> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let option of this.options) {
|
||||||
|
const ph = option.args.onPresentHandler;
|
||||||
|
if (ph && foundOptions[option.name]) {
|
||||||
|
ph(myArgs[option.name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedArgs[this.argKey].help) {
|
if (parsedArgs[this.argKey].help) {
|
||||||
this.printHelp(progname, parents);
|
this.printHelp(progname, parents);
|
||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
@ -546,7 +553,7 @@ export class Program<PN extends keyof any, T> {
|
|||||||
name: N,
|
name: N,
|
||||||
flagspec: string[],
|
flagspec: string[],
|
||||||
args: OptionArgs<boolean> = {},
|
args: OptionArgs<boolean> = {},
|
||||||
): Program<N, T & SubRecord<PN, N, boolean>> {
|
): Program<PN, T & SubRecord<PN, N, boolean>> {
|
||||||
this.mainCommand.flag(name, flagspec, args);
|
this.mainCommand.flag(name, flagspec, args);
|
||||||
return this as any;
|
return this as any;
|
||||||
}
|
}
|
||||||
|
@ -34,35 +34,30 @@ 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";
|
||||||
|
|
||||||
|
const logger = new Logger("helpers.ts");
|
||||||
|
|
||||||
const enableTracing = false;
|
|
||||||
|
|
||||||
class ConsoleBadge implements Badge {
|
class ConsoleBadge implements Badge {
|
||||||
startBusy(): void {
|
startBusy(): void {
|
||||||
enableTracing && console.log("NOTIFICATION: busy");
|
|
||||||
}
|
}
|
||||||
stopBusy(): void {
|
stopBusy(): void {
|
||||||
enableTracing && console.log("NOTIFICATION: busy end");
|
|
||||||
}
|
}
|
||||||
showNotification(): void {
|
showNotification(): void {
|
||||||
enableTracing && console.log("NOTIFICATION: show");
|
|
||||||
}
|
}
|
||||||
clearNotification(): void {
|
clearNotification(): void {
|
||||||
enableTracing && console.log("NOTIFICATION: cleared");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NodeHttpLib implements HttpRequestLibrary {
|
export class NodeHttpLib implements HttpRequestLibrary {
|
||||||
async get(url: string): Promise<import("../http").HttpResponse> {
|
async get(url: string): Promise<import("../http").HttpResponse> {
|
||||||
enableTracing && console.log("making GET request to", url);
|
|
||||||
try {
|
try {
|
||||||
const resp = await Axios({
|
const resp = await Axios({
|
||||||
method: "get",
|
method: "get",
|
||||||
url: url,
|
url: url,
|
||||||
responseType: "json",
|
responseType: "json",
|
||||||
});
|
});
|
||||||
enableTracing && console.log("got response", resp.data);
|
|
||||||
enableTracing && console.log("resp type", typeof resp.data);
|
|
||||||
return {
|
return {
|
||||||
responseJson: resp.data,
|
responseJson: resp.data,
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
@ -76,7 +71,6 @@ export class NodeHttpLib implements HttpRequestLibrary {
|
|||||||
url: string,
|
url: string,
|
||||||
body: any,
|
body: any,
|
||||||
): Promise<import("../http").HttpResponse> {
|
): Promise<import("../http").HttpResponse> {
|
||||||
enableTracing && console.log("making POST request to", url);
|
|
||||||
try {
|
try {
|
||||||
const resp = await Axios({
|
const resp = await Axios({
|
||||||
method: "post",
|
method: "post",
|
||||||
@ -84,8 +78,6 @@ export class NodeHttpLib implements HttpRequestLibrary {
|
|||||||
responseType: "json",
|
responseType: "json",
|
||||||
data: body,
|
data: body,
|
||||||
});
|
});
|
||||||
enableTracing && console.log("got response", resp.data);
|
|
||||||
enableTracing && console.log("resp type", typeof resp.data);
|
|
||||||
return {
|
return {
|
||||||
responseJson: resp.data,
|
responseJson: resp.data,
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
@ -149,7 +141,6 @@ export async function getDefaultNodeWallet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
myBackend.afterCommitCallback = async () => {
|
myBackend.afterCommitCallback = async () => {
|
||||||
console.log("DATABASE COMMITTED");
|
|
||||||
// Allow caller to stop persisting the wallet.
|
// Allow caller to stop persisting the wallet.
|
||||||
if (args.persistentStoragePath === undefined) {
|
if (args.persistentStoragePath === undefined) {
|
||||||
return;
|
return;
|
||||||
@ -219,7 +210,7 @@ export async function withdrawTestBalance(
|
|||||||
|
|
||||||
const bankUser = await bank.registerRandomUser();
|
const bankUser = await bank.registerRandomUser();
|
||||||
|
|
||||||
console.log("bank user", bankUser);
|
logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`)
|
||||||
|
|
||||||
const exchangePaytoUri = await myWallet.getExchangePaytoUri(
|
const exchangePaytoUri = await myWallet.getExchangePaytoUri(
|
||||||
exchangeBaseUrl,
|
exchangeBaseUrl,
|
||||||
@ -234,6 +225,5 @@ export async function withdrawTestBalance(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await myWallet.confirmReserve({ reservePub: reserveResponse.reservePub });
|
await myWallet.confirmReserve({ reservePub: reserveResponse.reservePub });
|
||||||
|
|
||||||
await myWallet.runUntilReserveDepleted(reservePub);
|
await myWallet.runUntilReserveDepleted(reservePub);
|
||||||
}
|
}
|
||||||
|
@ -30,10 +30,60 @@ import URI = require("urijs");
|
|||||||
* Connection to the *internal* merchant backend.
|
* Connection to the *internal* merchant backend.
|
||||||
*/
|
*/
|
||||||
export class MerchantBackendConnection {
|
export class MerchantBackendConnection {
|
||||||
constructor(
|
async refund(
|
||||||
public merchantBaseUrl: string,
|
orderId: string,
|
||||||
public apiKey: string,
|
reason: string,
|
||||||
) {}
|
refundAmount: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const reqUrl = new URI("refund").absoluteTo(this.merchantBaseUrl).href();
|
||||||
|
const refundReq = {
|
||||||
|
order_id: orderId,
|
||||||
|
reason,
|
||||||
|
refund: refundAmount,
|
||||||
|
};
|
||||||
|
const resp = await axios({
|
||||||
|
method: "post",
|
||||||
|
url: reqUrl,
|
||||||
|
data: refundReq,
|
||||||
|
responseType: "json",
|
||||||
|
headers: {
|
||||||
|
Authorization: `ApiKey ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (resp.status != 200) {
|
||||||
|
throw Error("failed to do refund");
|
||||||
|
}
|
||||||
|
console.log("response", resp.data);
|
||||||
|
const refundUri = resp.data.taler_refund_uri;
|
||||||
|
if (!refundUri) {
|
||||||
|
throw Error("no refund URI in response");
|
||||||
|
}
|
||||||
|
return refundUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(public merchantBaseUrl: string, public apiKey: string) {}
|
||||||
|
|
||||||
|
async authorizeTip(amount: string, justification: string) {
|
||||||
|
const reqUrl = new URI("tip-authorize").absoluteTo(this.merchantBaseUrl).href();
|
||||||
|
const tipReq = {
|
||||||
|
amount,
|
||||||
|
justification,
|
||||||
|
};
|
||||||
|
const resp = await axios({
|
||||||
|
method: "post",
|
||||||
|
url: reqUrl,
|
||||||
|
data: tipReq,
|
||||||
|
responseType: "json",
|
||||||
|
headers: {
|
||||||
|
Authorization: `ApiKey ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tipUri = resp.data.taler_tip_uri;
|
||||||
|
if (!tipUri) {
|
||||||
|
throw Error("response does not contain tip URI");
|
||||||
|
}
|
||||||
|
return tipUri;
|
||||||
|
}
|
||||||
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
amount: string,
|
amount: string,
|
||||||
|
@ -26,11 +26,16 @@ import { BridgeIDBFactory, MemoryBackend } from "idb-bridge";
|
|||||||
import { Logger } from "../logging";
|
import { Logger } from "../logging";
|
||||||
import * as Amounts from "../amounts";
|
import * as Amounts from "../amounts";
|
||||||
import { decodeCrock } from "../crypto/talerCrypto";
|
import { decodeCrock } from "../crypto/talerCrypto";
|
||||||
|
import { Bank } from "./bank";
|
||||||
|
|
||||||
const logger = new Logger("taler-wallet-cli.ts");
|
const logger = new Logger("taler-wallet-cli.ts");
|
||||||
|
|
||||||
const walletDbPath = os.homedir + "/" + ".talerwalletdb.json";
|
const walletDbPath = os.homedir + "/" + ".talerwalletdb.json";
|
||||||
|
|
||||||
|
function assertUnreachable(x: never): never {
|
||||||
|
throw new Error("Didn't expect to get here");
|
||||||
|
}
|
||||||
|
|
||||||
async function doPay(
|
async function doPay(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
payUrl: string,
|
payUrl: string,
|
||||||
@ -78,7 +83,7 @@ async function doPay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pay) {
|
if (pay) {
|
||||||
const payRes = await wallet.confirmPay(result.proposalId!, undefined);
|
const payRes = await wallet.confirmPay(result.proposalId, undefined);
|
||||||
console.log("paid!");
|
console.log("paid!");
|
||||||
} else {
|
} else {
|
||||||
console.log("not paying");
|
console.log("not paying");
|
||||||
@ -93,6 +98,12 @@ function applyVerbose(verbose: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printVersion() {
|
||||||
|
const info = require("../../../package.json");
|
||||||
|
console.log(`${info.version}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const walletCli = clk
|
const walletCli = clk
|
||||||
.program("wallet", {
|
.program("wallet", {
|
||||||
help: "Command line interface for the GNU Taler wallet.",
|
help: "Command line interface for the GNU Taler wallet.",
|
||||||
@ -101,6 +112,9 @@ const walletCli = clk
|
|||||||
help:
|
help:
|
||||||
"Inhibit running certain operations, useful for debugging and testing.",
|
"Inhibit running certain operations, useful for debugging and testing.",
|
||||||
})
|
})
|
||||||
|
.flag("version", ["-v", "--version"], {
|
||||||
|
onPresentHandler: printVersion,
|
||||||
|
})
|
||||||
.flag("verbose", ["-V", "--verbose"], {
|
.flag("verbose", ["-V", "--verbose"], {
|
||||||
help: "Enable verbose output.",
|
help: "Enable verbose output.",
|
||||||
});
|
});
|
||||||
@ -133,12 +147,21 @@ async function withWallet<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
walletCli
|
walletCli
|
||||||
.subcommand("", "balance", { help: "Show wallet balance." })
|
.subcommand("balance", "balance", { help: "Show wallet balance." })
|
||||||
|
.flag("json", ["--json"], {
|
||||||
|
help: "Show raw JSON.",
|
||||||
|
})
|
||||||
.action(async args => {
|
.action(async args => {
|
||||||
console.log("balance command called");
|
|
||||||
await withWallet(args, async wallet => {
|
await withWallet(args, async wallet => {
|
||||||
const balance = await wallet.getBalances();
|
const balance = await wallet.getBalances();
|
||||||
|
if (args.balance.json) {
|
||||||
console.log(JSON.stringify(balance, undefined, 2));
|
console.log(JSON.stringify(balance, undefined, 2));
|
||||||
|
} else {
|
||||||
|
const currencies = Object.keys(balance.byCurrency).sort();
|
||||||
|
for (const c of currencies) {
|
||||||
|
console.log(Amounts.toString(balance.byCurrency[c].available));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -205,15 +228,8 @@ walletCli
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { confirmTransferUrl } = await wallet.acceptWithdrawal(
|
const res = await wallet.acceptWithdrawal(uri, selectedExchange);
|
||||||
uri,
|
await wallet.processReserve(res.reservePub);
|
||||||
selectedExchange,
|
|
||||||
);
|
|
||||||
if (confirmTransferUrl) {
|
|
||||||
console.log("please confirm the transfer at", confirmTransferUrl);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("unrecognized URI");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -258,13 +274,39 @@ const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
|
|||||||
|
|
||||||
advancedCli
|
advancedCli
|
||||||
.subcommand("decode", "decode", {
|
.subcommand("decode", "decode", {
|
||||||
help: "Decode base32-crockford",
|
help: "Decode base32-crockford.",
|
||||||
})
|
})
|
||||||
.action(args => {
|
.action(args => {
|
||||||
const enc = fs.readFileSync(0, 'utf8');
|
const enc = fs.readFileSync(0, "utf8");
|
||||||
fs.writeFileSync(1, decodeCrock(enc.trim()))
|
fs.writeFileSync(1, decodeCrock(enc.trim()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
advancedCli
|
||||||
|
.subcommand("payPrepare", "pay-prepare", {
|
||||||
|
help: "Claim an order but don't pay yet.",
|
||||||
|
})
|
||||||
|
.requiredArgument("url", clk.STRING)
|
||||||
|
.action(async args => {
|
||||||
|
await withWallet(args, async wallet => {
|
||||||
|
const res = await wallet.preparePay(args.payPrepare.url);
|
||||||
|
switch (res.status) {
|
||||||
|
case "error":
|
||||||
|
console.log("error:", res.error);
|
||||||
|
break;
|
||||||
|
case "insufficient-balance":
|
||||||
|
console.log("insufficient balance");
|
||||||
|
break;
|
||||||
|
case "paid":
|
||||||
|
console.log("already paid");
|
||||||
|
break;
|
||||||
|
case "payment-possible":
|
||||||
|
console.log("payment possible");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assertUnreachable(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
advancedCli
|
advancedCli
|
||||||
.subcommand("refresh", "force-refresh", {
|
.subcommand("refresh", "force-refresh", {
|
||||||
@ -288,7 +330,9 @@ advancedCli
|
|||||||
console.log(`coin ${coin.coinPub}`);
|
console.log(`coin ${coin.coinPub}`);
|
||||||
console.log(` status ${coin.status}`);
|
console.log(` status ${coin.status}`);
|
||||||
console.log(` exchange ${coin.exchangeBaseUrl}`);
|
console.log(` exchange ${coin.exchangeBaseUrl}`);
|
||||||
console.log(` remaining amount ${Amounts.toString(coin.currentAmount)}`);
|
console.log(
|
||||||
|
` remaining amount ${Amounts.toString(coin.currentAmount)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -324,12 +368,11 @@ testCli
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("taler pay URI:", talerPayUri);
|
console.log("taler pay URI:", talerPayUri);
|
||||||
await withWallet(args, async (wallet) => {
|
await withWallet(args, async wallet => {
|
||||||
await doPay(wallet, talerPayUri, { alwaysYes: true });
|
await doPay(wallet, talerPayUri, { alwaysYes: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
testCli
|
testCli
|
||||||
.subcommand("integrationtestCmd", "integrationtest", {
|
.subcommand("integrationtestCmd", "integrationtest", {
|
||||||
help: "Run integration test with bank, exchange and merchant.",
|
help: "Run integration test with bank, exchange and merchant.",
|
||||||
@ -377,7 +420,74 @@ testCli
|
|||||||
});
|
});
|
||||||
|
|
||||||
testCli
|
testCli
|
||||||
.subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode")
|
.subcommand("genTipUri", "gen-tip-uri", {
|
||||||
|
help: "Generate a taler://tip URI.",
|
||||||
|
})
|
||||||
|
.requiredOption("amount", ["-a", "--amount"], clk.STRING, {
|
||||||
|
default: "TESTKUDOS:10",
|
||||||
|
})
|
||||||
|
.action(async args => {
|
||||||
|
const merchantBackend = new MerchantBackendConnection(
|
||||||
|
"https://backend.test.taler.net/",
|
||||||
|
"sandbox",
|
||||||
|
);
|
||||||
|
const tipUri = await merchantBackend.authorizeTip("TESTKUDOS:10", "test");
|
||||||
|
console.log(tipUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
testCli
|
||||||
|
.subcommand("genRefundUri", "gen-refund-uri", {
|
||||||
|
help: "Generate a taler://refund URI.",
|
||||||
|
})
|
||||||
|
.requiredOption("amount", ["-a", "--amount"], clk.STRING, {
|
||||||
|
default: "TESTKUDOS:5",
|
||||||
|
})
|
||||||
|
.requiredOption("refundAmount", ["-r", "--refund"], clk.STRING, {
|
||||||
|
default: "TESTKUDOS:3",
|
||||||
|
})
|
||||||
|
.requiredOption("summary", ["-s", "--summary"], clk.STRING, {
|
||||||
|
default: "Test Payment (for refund)",
|
||||||
|
})
|
||||||
|
.action(async args => {
|
||||||
|
const cmdArgs = args.genRefundUri;
|
||||||
|
const merchantBackend = new MerchantBackendConnection(
|
||||||
|
"https://backend.test.taler.net/",
|
||||||
|
"sandbox",
|
||||||
|
);
|
||||||
|
const orderResp = await merchantBackend.createOrder(
|
||||||
|
cmdArgs.amount,
|
||||||
|
cmdArgs.summary,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
console.log("created new order with order ID", orderResp.orderId);
|
||||||
|
const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId);
|
||||||
|
const talerPayUri = checkPayResp.taler_pay_uri;
|
||||||
|
if (!talerPayUri) {
|
||||||
|
console.error("fatal: no taler pay URI received from backend");
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await withWallet(args, async wallet => {
|
||||||
|
await doPay(wallet, talerPayUri, { alwaysYes: true });
|
||||||
|
});
|
||||||
|
const refundUri = await merchantBackend.refund(
|
||||||
|
orderResp.orderId,
|
||||||
|
"test refund",
|
||||||
|
cmdArgs.refundAmount,
|
||||||
|
);
|
||||||
|
console.log(refundUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
testCli
|
||||||
|
.subcommand("genPayUri", "gen-pay-uri", {
|
||||||
|
help: "Generate a taler://pay URI.",
|
||||||
|
})
|
||||||
|
.flag("qrcode", ["--qr"], {
|
||||||
|
help: "Show a QR code with the taler://pay URI",
|
||||||
|
})
|
||||||
|
.flag("wait", ["--wait"], {
|
||||||
|
help: "Wait until payment has completed",
|
||||||
|
})
|
||||||
.requiredOption("amount", ["-a", "--amount"], clk.STRING, {
|
.requiredOption("amount", ["-a", "--amount"], clk.STRING, {
|
||||||
default: "TESTKUDOS:1",
|
default: "TESTKUDOS:1",
|
||||||
})
|
})
|
||||||
@ -385,8 +495,7 @@ testCli
|
|||||||
default: "Test Payment",
|
default: "Test Payment",
|
||||||
})
|
})
|
||||||
.action(async args => {
|
.action(async args => {
|
||||||
const cmdArgs = args.testMerchantQrcodeCmd;
|
const cmdArgs = args.genPayUri;
|
||||||
applyVerbose(args.wallet.verbose);
|
|
||||||
console.log("creating order");
|
console.log("creating order");
|
||||||
const merchantBackend = new MerchantBackendConnection(
|
const merchantBackend = new MerchantBackendConnection(
|
||||||
"https://backend.test.taler.net/",
|
"https://backend.test.taler.net/",
|
||||||
@ -399,7 +508,6 @@ testCli
|
|||||||
);
|
);
|
||||||
console.log("created new order with order ID", orderResp.orderId);
|
console.log("created new order with order ID", orderResp.orderId);
|
||||||
const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId);
|
const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId);
|
||||||
const qrcode = qrcodeGenerator(0, "M");
|
|
||||||
const talerPayUri = checkPayResp.taler_pay_uri;
|
const talerPayUri = checkPayResp.taler_pay_uri;
|
||||||
if (!talerPayUri) {
|
if (!talerPayUri) {
|
||||||
console.error("fatal: no taler pay URI received from backend");
|
console.error("fatal: no taler pay URI received from backend");
|
||||||
@ -407,9 +515,13 @@ testCli
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("taler pay URI:", talerPayUri);
|
console.log("taler pay URI:", talerPayUri);
|
||||||
|
if (cmdArgs.qrcode) {
|
||||||
|
const qrcode = qrcodeGenerator(0, "M");
|
||||||
qrcode.addData(talerPayUri);
|
qrcode.addData(talerPayUri);
|
||||||
qrcode.make();
|
qrcode.make();
|
||||||
console.log(qrcode.createASCII());
|
console.log(qrcode.createASCII());
|
||||||
|
}
|
||||||
|
if (cmdArgs.wait) {
|
||||||
console.log("waiting for payment ...");
|
console.log("waiting for payment ...");
|
||||||
while (1) {
|
while (1) {
|
||||||
await asyncSleep(500);
|
await asyncSleep(500);
|
||||||
@ -421,6 +533,7 @@ testCli
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
testCli
|
testCli
|
||||||
|
@ -47,6 +47,8 @@ function fakeCwd(current: string, value: string, feeDeposit: string): types.Coin
|
|||||||
denomSig: "(mock)",
|
denomSig: "(mock)",
|
||||||
exchangeBaseUrl: "(mock)",
|
exchangeBaseUrl: "(mock)",
|
||||||
reservePub: "(mock)",
|
reservePub: "(mock)",
|
||||||
|
coinIndex: -1,
|
||||||
|
withdrawSessionId: "",
|
||||||
status: dbTypes.CoinStatus.Fresh,
|
status: dbTypes.CoinStatus.Fresh,
|
||||||
},
|
},
|
||||||
denom: {
|
denom: {
|
||||||
|
873
src/wallet.ts
873
src/wallet.ts
File diff suppressed because it is too large
Load Diff
@ -465,14 +465,14 @@ export type PreparePayResult =
|
|||||||
|
|
||||||
export interface PreparePayResultPaymentPossible {
|
export interface PreparePayResultPaymentPossible {
|
||||||
status: "payment-possible";
|
status: "payment-possible";
|
||||||
proposalId: number;
|
proposalId: string;
|
||||||
contractTerms: ContractTerms;
|
contractTerms: ContractTerms;
|
||||||
totalFees: AmountJson;
|
totalFees: AmountJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreparePayResultInsufficientBalance {
|
export interface PreparePayResultInsufficientBalance {
|
||||||
status: "insufficient-balance";
|
status: "insufficient-balance";
|
||||||
proposalId: number;
|
proposalId: string;
|
||||||
contractTerms: ContractTerms;
|
contractTerms: ContractTerms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,8 +523,10 @@ export interface WalletDiagnostics {
|
|||||||
|
|
||||||
export interface PendingWithdrawOperation {
|
export interface PendingWithdrawOperation {
|
||||||
type: "withdraw";
|
type: "withdraw";
|
||||||
stage: string;
|
|
||||||
reservePub: string;
|
reservePub: string;
|
||||||
|
withdrawSessionId: string;
|
||||||
|
numCoinsWithdrawn: number;
|
||||||
|
numCoinsTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingRefreshOperation {
|
export interface PendingRefreshOperation {
|
||||||
@ -561,22 +563,47 @@ export interface PendingReserveOperation {
|
|||||||
stage: string;
|
stage: string;
|
||||||
timestampCreated: Timestamp;
|
timestampCreated: Timestamp;
|
||||||
reserveType: string;
|
reserveType: string;
|
||||||
|
reservePub: string;
|
||||||
|
bankWithdrawConfirmUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingRefreshOperation {
|
export interface PendingRefreshOperation {
|
||||||
type: "refresh";
|
type: "refresh";
|
||||||
lastError?: OperationError;
|
lastError?: OperationError;
|
||||||
|
refreshSessionId: string;
|
||||||
oldCoinPub: string;
|
oldCoinPub: string;
|
||||||
refreshStatus: string;
|
refreshStatus: string;
|
||||||
refreshOutputSize: number;
|
refreshOutputSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingPlanchetOperation {
|
||||||
|
type: "planchet";
|
||||||
|
coinPub: string;
|
||||||
|
reservePub: string;
|
||||||
|
lastError?: OperationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingDirtyCoinOperation {
|
||||||
|
type: "dirty-coin";
|
||||||
|
coinPub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingProposalOperation {
|
||||||
|
type: "proposal";
|
||||||
|
merchantBaseUrl: string;
|
||||||
|
proposalTimestamp: Timestamp;
|
||||||
|
proposalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PendingOperationInfo =
|
export type PendingOperationInfo =
|
||||||
| PendingWithdrawOperation
|
| PendingWithdrawOperation
|
||||||
| PendingReserveOperation
|
| PendingReserveOperation
|
||||||
| PendingBugOperation
|
| PendingBugOperation
|
||||||
|
| PendingPlanchetOperation
|
||||||
|
| PendingDirtyCoinOperation
|
||||||
| PendingExchangeUpdateOperation
|
| PendingExchangeUpdateOperation
|
||||||
| PendingRefreshOperation;
|
| PendingRefreshOperation
|
||||||
|
| PendingProposalOperation;
|
||||||
|
|
||||||
export interface PendingOperationsResponse {
|
export interface PendingOperationsResponse {
|
||||||
pendingOperations: PendingOperationInfo[];
|
pendingOperations: PendingOperationInfo[];
|
||||||
@ -614,3 +641,17 @@ export function getTimestampNow(): Timestamp {
|
|||||||
t_ms: new Date().getTime(),
|
t_ms: new Date().getTime(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PlanchetCreationResult {
|
||||||
|
coinPub: string;
|
||||||
|
coinPriv: string;
|
||||||
|
reservePub: string;
|
||||||
|
denomPubHash: string;
|
||||||
|
denomPub: string;
|
||||||
|
blindingKey: string;
|
||||||
|
withdrawSig: string;
|
||||||
|
coinEv: string;
|
||||||
|
exchangeBaseUrl: string;
|
||||||
|
coinValue: AmountJson;
|
||||||
|
}
|
@ -66,7 +66,7 @@ export interface MessageMap {
|
|||||||
response: void;
|
response: void;
|
||||||
};
|
};
|
||||||
"confirm-pay": {
|
"confirm-pay": {
|
||||||
request: { proposalId: number; sessionId?: string };
|
request: { proposalId: string; sessionId?: string };
|
||||||
response: walletTypes.ConfirmPayResult;
|
response: walletTypes.ConfirmPayResult;
|
||||||
};
|
};
|
||||||
"exchange-info": {
|
"exchange-info": {
|
||||||
@ -113,9 +113,9 @@ export interface MessageMap {
|
|||||||
request: { reservePub: string };
|
request: { reservePub: string };
|
||||||
response: dbTypes.ReserveRecord[];
|
response: dbTypes.ReserveRecord[];
|
||||||
};
|
};
|
||||||
"get-precoins": {
|
"get-planchets": {
|
||||||
request: { exchangeBaseUrl: string };
|
request: { exchangeBaseUrl: string };
|
||||||
response: dbTypes.PreCoinRecord[];
|
response: dbTypes.PlanchetRecord[];
|
||||||
};
|
};
|
||||||
"get-denoms": {
|
"get-denoms": {
|
||||||
request: { exchangeBaseUrl: string };
|
request: { exchangeBaseUrl: string };
|
||||||
|
@ -57,7 +57,7 @@ function Payback() {
|
|||||||
<div>
|
<div>
|
||||||
{reserves.map(r => (
|
{reserves.map(r => (
|
||||||
<div>
|
<div>
|
||||||
<h2>Reserve for ${renderAmount(r.currentAmount!)}</h2>
|
<h2>Reserve for ${renderAmount(r.withdrawRemainingAmount)}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Exchange: ${r.exchangeBaseUrl}</li>
|
<li>Exchange: ${r.exchangeBaseUrl}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -28,7 +28,7 @@ import {
|
|||||||
CurrencyRecord,
|
CurrencyRecord,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
ExchangeRecord,
|
ExchangeRecord,
|
||||||
PreCoinRecord,
|
PlanchetRecord,
|
||||||
ReserveRecord,
|
ReserveRecord,
|
||||||
} from "../dbTypes";
|
} from "../dbTypes";
|
||||||
import {
|
import {
|
||||||
@ -174,10 +174,10 @@ export function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all precoins withdrawn from the given exchange.
|
* Get all planchets withdrawn from the given exchange.
|
||||||
*/
|
*/
|
||||||
export function getPreCoins(exchangeBaseUrl: string): Promise<PreCoinRecord[]> {
|
export function getPlanchets(exchangeBaseUrl: string): Promise<PlanchetRecord[]> {
|
||||||
return callBackend("get-precoins", { exchangeBaseUrl });
|
return callBackend("get-planchets", { exchangeBaseUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -207,7 +207,7 @@ export function payback(coinPub: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Pay for a proposal.
|
* Pay for a proposal.
|
||||||
*/
|
*/
|
||||||
export function confirmPay(proposalId: number, sessionId: string | undefined): Promise<ConfirmPayResult> {
|
export function confirmPay(proposalId: string, sessionId: string | undefined): Promise<ConfirmPayResult> {
|
||||||
return callBackend("confirm-pay", { proposalId, sessionId });
|
return callBackend("confirm-pay", { proposalId, sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,8 +117,8 @@ async function handleMessage(
|
|||||||
return needsWallet().confirmReserve(req);
|
return needsWallet().confirmReserve(req);
|
||||||
}
|
}
|
||||||
case "confirm-pay": {
|
case "confirm-pay": {
|
||||||
if (typeof detail.proposalId !== "number") {
|
if (typeof detail.proposalId !== "string") {
|
||||||
throw Error("proposalId must be number");
|
throw Error("proposalId must be string");
|
||||||
}
|
}
|
||||||
return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
|
return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
|
||||||
}
|
}
|
||||||
@ -178,11 +178,11 @@ async function handleMessage(
|
|||||||
}
|
}
|
||||||
return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl);
|
return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl);
|
||||||
}
|
}
|
||||||
case "get-precoins": {
|
case "get-planchets": {
|
||||||
if (typeof detail.exchangeBaseUrl !== "string") {
|
if (typeof detail.exchangeBaseUrl !== "string") {
|
||||||
return Promise.reject(Error("exchangBaseUrl missing"));
|
return Promise.reject(Error("exchangBaseUrl missing"));
|
||||||
}
|
}
|
||||||
return needsWallet().getPreCoins(detail.exchangeBaseUrl);
|
return needsWallet().getPlanchets(detail.exchangeBaseUrl);
|
||||||
}
|
}
|
||||||
case "get-denoms": {
|
case "get-denoms": {
|
||||||
if (typeof detail.exchangeBaseUrl !== "string") {
|
if (typeof detail.exchangeBaseUrl !== "string") {
|
||||||
@ -658,8 +658,8 @@ export async function wxMain() {
|
|||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
console.warn("wallet not available while handling header");
|
console.warn("wallet not available while handling header");
|
||||||
}
|
}
|
||||||
if (details.statusCode === 402) {
|
if (details.statusCode === 402 || details.statusCode === 202) {
|
||||||
console.log(`got 402 from ${details.url}`);
|
console.log(`got 402/202 from ${details.url}`);
|
||||||
for (let header of details.responseHeaders || []) {
|
for (let header of details.responseHeaders || []) {
|
||||||
if (header.name.toLowerCase() === "taler") {
|
if (header.name.toLowerCase() === "taler") {
|
||||||
const talerUri = header.value || "";
|
const talerUri = header.value || "";
|
||||||
@ -705,6 +705,15 @@ export async function wxMain() {
|
|||||||
talerRefundUri: talerUri,
|
talerRefundUri: talerUri,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if (talerUri.startsWith("taler://notify-reserve/")) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
const w = currentWallet;
|
||||||
|
if (!w) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.handleNotifyReserve();
|
||||||
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("Unknown action in taler:// URI, ignoring.");
|
console.warn("Unknown action in taler:// URI, ignoring.");
|
||||||
}
|
}
|
||||||
|
@ -3417,10 +3417,10 @@ iconv-lite@0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer ">= 2.1.2 < 3"
|
safer-buffer ">= 2.1.2 < 3"
|
||||||
|
|
||||||
idb-bridge@^0.0.14:
|
idb-bridge@^0.0.15:
|
||||||
version "0.0.14"
|
version "0.0.15"
|
||||||
resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.14.tgz#5fd50cd68b574df0eb6b1a960cef0cb984a21ded"
|
resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.15.tgz#3fddc91b9aab775fae273d02b272205c6090d270"
|
||||||
integrity sha512-jc9ZYGhhIrW6nh/pWyycGWzCmsLTFQ0iMY61lN+y9YcIOCxREpAkZxdfmhwNL7H0RvsYp7iJv0GH7ujs7HPC+g==
|
integrity sha512-xuZM/i4vCm/NkqyrKNJDEuBaZK7M2kyj+1F4hDGqtEJZSmQMSV3v9A6Ie3fR12VXDKIbMr7uV22eWjIKwSosOA==
|
||||||
|
|
||||||
ieee754@^1.1.4:
|
ieee754@^1.1.4:
|
||||||
version "1.1.13"
|
version "1.1.13"
|
||||||
|
Loading…
Reference in New Issue
Block a user