implement peer to peer push payments

This commit is contained in:
Florian Dold 2022-08-09 15:00:45 +02:00
parent fb8372dfbf
commit ac8f116780
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
28 changed files with 1095 additions and 1632 deletions

View File

@ -28,15 +28,14 @@ interface Props {
mobile?: boolean;
}
const VERSION: string = process.env.__VERSION__ || "dev";
const GIT_HASH: string | undefined = process.env.__GIT_HASH__;
// @ts-ignore
const maybeEnv = process?.env || {};
const VERSION: string = maybeEnv.__VERSION__ || "dev";
const GIT_HASH: string | undefined = maybeEnv.__GIT_HASH__;
const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
export function Sidebar({ mobile }: Props): VNode {
// const config = useConfigContext();
const config = { version: "none" };
// FIXME: add replacement for __VERSION__ with the current version
const process = { env: { __VERSION__: "0.0.0" } };
const reducer = useAnastasisContext()!;
function saveSession(): void {

View File

@ -186,7 +186,7 @@ class UnionCodecBuilder<
throw new DecodingError(
`expected tag for ${objectDisplayName} at ${renderContext(
c,
)}.${discriminator}`,
)}.${String(discriminator)}`,
);
}
const alt = alternatives.get(d);
@ -194,7 +194,7 @@ class UnionCodecBuilder<
throw new DecodingError(
`unknown tag for ${objectDisplayName} ${d} at ${renderContext(
c,
)}.${discriminator}`,
)}.${String(discriminator)}`,
);
}
const altDecoded = alt.codec.decode(x);

View File

@ -18,8 +18,13 @@
* Imports.
*/
import test from "ava";
import { initNodePrng } from "./prng-node.js";
import { ContractTermsUtil } from "./contractTerms.js";
// Since we import nacl-fast directly (and not via index.node.ts), we need to
// init the PRNG manually.
initNodePrng();
test("contract terms canon hashing", (t) => {
const cReq = {
foo: 42,

View File

@ -381,7 +381,7 @@ test("taler age restriction crypto", async (t) => {
const pub2Ref = await Edx25519.getPublic(priv2);
t.is(pub2, pub2Ref);
t.deepEqual(pub2, pub2Ref);
});
test("edx signing", async (t) => {
@ -390,21 +390,13 @@ test("edx signing", async (t) => {
const msg = stringToBytes("hello world");
const sig = nacl.crypto_edx25519_sign_detached(
msg,
priv1,
pub1,
);
const sig = nacl.crypto_edx25519_sign_detached(msg, priv1, pub1);
t.true(
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
);
t.true(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
sig[0]++;
t.false(
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
);
t.false(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
});
test("edx test vector", async (t) => {
@ -422,18 +414,18 @@ test("edx test vector", async (t) => {
{
const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx));
t.is(pub1Prime, decodeCrock(tv.pub1_edx));
t.deepEqual(pub1Prime, decodeCrock(tv.pub1_edx));
}
const pub2Prime = await Edx25519.publicKeyDerive(
decodeCrock(tv.pub1_edx),
decodeCrock(tv.seed),
);
t.is(pub2Prime, decodeCrock(tv.pub2_edx));
t.deepEqual(pub2Prime, decodeCrock(tv.pub2_edx));
const priv2Prime = await Edx25519.privateKeyDerive(
decodeCrock(tv.priv1_edx),
decodeCrock(tv.seed),
);
t.is(priv2Prime, decodeCrock(tv.priv2_edx));
t.deepEqual(priv2Prime, decodeCrock(tv.priv2_edx));
});

View File

@ -20,6 +20,8 @@ import {
parseWithdrawUri,
parseRefundUri,
parseTipUri,
parsePayPushUri,
constructPayPushUri,
} from "./taleruri.js";
test("taler pay url parsing: wrong scheme", (t) => {
@ -182,3 +184,44 @@ test("taler tip pickup uri with instance and prefix", (t) => {
t.is(r1.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/");
t.is(r1.merchantTipId, "tipid");
});
test("taler peer to peer push URI", (t) => {
const url1 = "taler://pay-push/exch.example.com/foo";
const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
t.is(r1.exchangeBaseUrl, "https://exch.example.com/");
t.is(r1.contractPriv, "foo");
});
test("taler peer to peer push URI (path)", (t) => {
const url1 = "taler://pay-push/exch.example.com:123/bla/foo";
const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/");
t.is(r1.contractPriv, "foo");
});
test("taler peer to peer push URI (http)", (t) => {
const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo";
const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/");
t.is(r1.contractPriv, "foo");
});
test("taler peer to peer push URI (construction)", (t) => {
const url = constructPayPushUri({
exchangeBaseUrl: "https://foo.example.com/bla/",
contractPriv: "123",
});
t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123");
});

View File

@ -15,7 +15,7 @@
*/
import { canonicalizeBaseUrl } from "./helpers.js";
import { URLSearchParams } from "./url.js";
import { URLSearchParams, URL } from "./url.js";
export interface PayUriResult {
merchantBaseUrl: string;
@ -40,6 +40,11 @@ export interface TipUriResult {
merchantBaseUrl: string;
}
export interface PayPushUriResult {
exchangeBaseUrl: string;
contractPriv: string;
}
/**
* Parse a taler[+http]://withdraw URI.
* Return undefined if not passed a valid URI.
@ -79,6 +84,7 @@ export enum TalerUriType {
TalerTip = "taler-tip",
TalerRefund = "taler-refund",
TalerNotifyReserve = "taler-notify-reserve",
TalerPayPush = "pay-push",
Unknown = "unknown",
}
@ -111,6 +117,12 @@ export function classifyTalerUri(s: string): TalerUriType {
if (sl.startsWith("taler+http://withdraw/")) {
return TalerUriType.TalerWithdraw;
}
if (sl.startsWith("taler://pay-push/")) {
return TalerUriType.TalerPayPush;
}
if (sl.startsWith("taler+http://pay-push/")) {
return TalerUriType.TalerPayPush;
}
if (sl.startsWith("taler://notify-reserve/")) {
return TalerUriType.TalerNotifyReserve;
}
@ -176,6 +188,28 @@ export function parsePayUri(s: string): PayUriResult | undefined {
};
}
export function parsePayPushUri(s: string): PayPushUriResult | undefined {
const pi = parseProtoInfo(s, "pay-push");
if (!pi) {
return undefined;
}
const c = pi?.rest.split("?");
const parts = c[0].split("/");
if (parts.length < 2) {
return undefined;
}
const host = parts[0].toLowerCase();
const contractPriv = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
const p = [host, ...pathSegments].join("/");
const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
return {
exchangeBaseUrl,
contractPriv,
};
}
/**
* Parse a taler[+http]://tip URI.
* Return undefined if not passed a valid URI.
@ -228,3 +262,24 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
orderId,
};
}
export function constructPayPushUri(args: {
exchangeBaseUrl: string;
contractPriv: string;
}): string {
const url = new URL(args.exchangeBaseUrl);
let proto: string;
if (url.protocol === "https:") {
proto = "taler";
} else if (url.protocol === "http:") {
proto = "taler+http";
} else {
throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
}
if (!url.pathname.endsWith("/")) {
throw Error(
`exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`,
);
}
return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`;
}

View File

@ -92,6 +92,14 @@ export namespace Duration {
return { d_ms: deadline.t_ms - now.t_ms };
}
export function max(d1: Duration, d2: Duration): Duration {
return durationMax(d1, d2);
}
export function min(d1: Duration, d2: Duration): Duration {
return durationMin(d1, d2);
}
export function toIntegerYears(d: Duration): number {
if (typeof d.d_ms !== "number") {
throw Error("infinite duration");

View File

@ -858,10 +858,11 @@ interface GetContractTermsDetailsRequest {
proposalId: string;
}
export const codecForGetContractTermsDetails = (): Codec<GetContractTermsDetailsRequest> =>
buildCodecForObject<GetContractTermsDetailsRequest>()
.property("proposalId", codecForString())
.build("GetContractTermsDetails");
export const codecForGetContractTermsDetails =
(): Codec<GetContractTermsDetailsRequest> =>
buildCodecForObject<GetContractTermsDetailsRequest>()
.property("proposalId", codecForString())
.build("GetContractTermsDetails");
export interface PreparePayRequest {
talerPayUri: string;
@ -1280,6 +1281,7 @@ export interface InitiatePeerPushPaymentResponse {
pursePub: string;
mergePriv: string;
contractPriv: string;
talerUri: string;
}
export const codecForInitiatePeerPushPaymentRequest =
@ -1290,32 +1292,30 @@ export const codecForInitiatePeerPushPaymentRequest =
.build("InitiatePeerPushPaymentRequest");
export interface CheckPeerPushPaymentRequest {
exchangeBaseUrl: string;
pursePub: string;
contractPriv: string;
talerUri: string;
}
export interface CheckPeerPushPaymentResponse {
contractTerms: any;
amount: AmountString;
peerPushPaymentIncomingId: string;
}
export const codecForCheckPeerPushPaymentRequest =
(): Codec<CheckPeerPushPaymentRequest> =>
buildCodecForObject<CheckPeerPushPaymentRequest>()
.property("pursePub", codecForString())
.property("contractPriv", codecForString())
.property("exchangeBaseUrl", codecForString())
.property("talerUri", codecForString())
.build("CheckPeerPushPaymentRequest");
export interface AcceptPeerPushPaymentRequest {
exchangeBaseUrl: string;
pursePub: string;
/**
* Transparent identifier of the incoming peer push payment.
*/
peerPushPaymentIncomingId: string;
}
export const codecForAcceptPeerPushPaymentRequest =
(): Codec<AcceptPeerPushPaymentRequest> =>
buildCodecForObject<AcceptPeerPushPaymentRequest>()
.property("pursePub", codecForString())
.property("exchangeBaseUrl", codecForString())
.property("peerPushPaymentIncomingId", codecForString())
.build("AcceptPeerPushPaymentRequest");

View File

@ -70,7 +70,7 @@ import {
TipCreateConfirmation,
TipCreateRequest,
TippingReserveStatus,
} from "./merchantApiTypes";
} from "./merchantApiTypes.js";
const exec = util.promisify(require("child_process").exec);
@ -478,14 +478,14 @@ class BankServiceBase {
protected globalTestState: GlobalTestState,
protected bankConfig: BankConfig,
protected configFile: string,
) { }
) {}
}
/**
* Work in progress. The key point is that both Sandbox and Nexus
* will be configured and started by this class.
*/
class EufinBankService extends BankServiceBase implements BankServiceHandle {
class LibEuFinBankService extends BankServiceBase implements BankServiceHandle {
sandboxProc: ProcessWrapper | undefined;
nexusProc: ProcessWrapper | undefined;
@ -494,8 +494,8 @@ class EufinBankService extends BankServiceBase implements BankServiceHandle {
static async create(
gc: GlobalTestState,
bc: BankConfig,
): Promise<EufinBankService> {
return new EufinBankService(gc, bc, "foo");
): Promise<LibEuFinBankService> {
return new LibEuFinBankService(gc, bc, "foo");
}
get port() {
@ -761,7 +761,10 @@ class EufinBankService extends BankServiceBase implements BankServiceHandle {
}
}
class PybankService extends BankServiceBase implements BankServiceHandle {
/**
* Implementation of the bank service using the "taler-fakebank-run" tool.
*/
class FakebankService extends BankServiceBase implements BankServiceHandle {
proc: ProcessWrapper | undefined;
http = new NodeHttpLib();
@ -769,41 +772,23 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
static async create(
gc: GlobalTestState,
bc: BankConfig,
): Promise<PybankService> {
): Promise<FakebankService> {
const config = new Configuration();
setTalerPaths(config, gc.testDir + "/talerhome");
config.setString("taler", "currency", bc.currency);
config.setString("bank", "database", bc.database);
config.setString("bank", "http_port", `${bc.httpPort}`);
config.setString("bank", "serve", "http");
config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
config.setString(
"bank",
"allow_registrations",
bc.allowRegistrations ? "yes" : "no",
);
const cfgFilename = gc.testDir + "/bank.conf";
config.write(cfgFilename);
await sh(
gc,
"taler-bank-manage_django",
`taler-bank-manage -c '${cfgFilename}' django migrate`,
);
await sh(
gc,
"taler-bank-manage_django",
`taler-bank-manage -c '${cfgFilename}' django provide_accounts`,
);
return new PybankService(gc, bc, cfgFilename);
return new FakebankService(gc, bc, cfgFilename);
}
setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
const config = Configuration.load(this.configFile);
config.setString("bank", "suggested_exchange", e.baseUrl);
config.setString("bank", "suggested_exchange_payto", exchangePayto);
config.write(this.configFile);
}
@ -815,21 +800,6 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
accountName: string,
password: string,
): Promise<HarnessExchangeBankAccount> {
await sh(
this.globalTestState,
"taler-bank-manage_django",
`taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`,
);
await sh(
this.globalTestState,
"taler-bank-manage_django",
`taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`,
);
await sh(
this.globalTestState,
"taler-bank-manage_django",
`taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`,
);
return {
accountName: accountName,
accountPassword: password,
@ -844,8 +814,8 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
async start(): Promise<void> {
this.proc = this.globalTestState.spawnService(
"taler-bank-manage",
["-c", this.configFile, "serve"],
"taler-fakebank-run",
["-c", this.configFile],
"bank",
);
}
@ -857,7 +827,7 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
}
// Use libeufin bank instead of pybank.
const useLibeufinBank = process.env.WALLET_HARNESS_WITH_EUFIN;
const useLibeufinBank = true;
/**
* Return a euFin or a pyBank implementation of
@ -866,21 +836,21 @@ const useLibeufinBank = process.env.WALLET_HARNESS_WITH_EUFIN;
* on a particular env variable.
*/
function getBankServiceImpl(): {
prototype: typeof PybankService.prototype;
create: typeof PybankService.create;
prototype: typeof FakebankService.prototype;
create: typeof FakebankService.create;
} {
if (useLibeufinBank)
return {
prototype: EufinBankService.prototype,
create: EufinBankService.create,
prototype: LibEuFinBankService.prototype,
create: LibEuFinBankService.create,
};
return {
prototype: PybankService.prototype,
create: PybankService.create,
prototype: FakebankService.prototype,
create: FakebankService.create,
};
}
export type BankService = PybankService;
export type BankService = FakebankService;
export const BankService = getBankServiceImpl();
export class FakeBankService {
@ -923,7 +893,7 @@ export class FakeBankService {
private globalTestState: GlobalTestState,
private bankConfig: FakeBankConfig,
private configFile: string,
) { }
) {}
async start(): Promise<void> {
this.proc = this.globalTestState.spawnService(
@ -1189,7 +1159,7 @@ export class ExchangeService implements ExchangeServiceInterface {
private exchangeConfig: ExchangeConfig,
private configFilename: string,
private keyPair: EddsaKeyPair,
) { }
) {}
get name() {
return this.exchangeConfig.name;
@ -1442,7 +1412,7 @@ export class MerchantApiClient {
constructor(
private baseUrl: string,
public readonly auth: MerchantAuthConfiguration,
) { }
) {}
async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
const url = new URL("private/auth", this.baseUrl);
@ -1635,7 +1605,7 @@ export class MerchantService implements MerchantServiceInterface {
private globalState: GlobalTestState,
private merchantConfig: MerchantConfig,
private configFilename: string,
) { }
) {}
private currentTimetravel: Duration | undefined;
@ -1947,8 +1917,10 @@ export class WalletCli {
const resp = await sh(
self.globalTestState,
`wallet-${self.name}`,
`taler-wallet-cli ${self.timetravelArg ?? ""
} --no-throttle -LTRACE --wallet-db '${self.dbfile
`taler-wallet-cli ${
self.timetravelArg ?? ""
} --no-throttle -LTRACE --wallet-db '${
self.dbfile
}' api '${op}' ${shellWrap(JSON.stringify(payload))}`,
);
console.log("--- wallet core response ---");

View File

@ -36,7 +36,7 @@ import {
runCommand,
setupDb,
sh,
getRandomIban
getRandomIban,
} from "../harness/harness.js";
import {
LibeufinSandboxApi,
@ -53,13 +53,10 @@ import {
CreateAnastasisFacadeRequest,
PostNexusTaskRequest,
PostNexusPermissionRequest,
CreateNexusUserRequest
CreateNexusUserRequest,
} from "../harness/libeufin-apis.js";
export {
LibeufinSandboxApi,
LibeufinNexusApi
}
export { LibeufinSandboxApi, LibeufinNexusApi };
export interface LibeufinServices {
libeufinSandbox: LibeufinSandboxService;
@ -206,6 +203,16 @@ export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
}
async start(): Promise<void> {
await sh(
this.globalTestState,
"libeufin-sandbox-config",
"libeufin-sandbox config default",
{
...process.env,
LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
},
);
this.sandboxProc = this.globalTestState.spawnService(
"libeufin-sandbox",
["serve", "--port", `${this.sandboxConfig.httpPort}`],
@ -235,7 +242,8 @@ export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
debit: string,
credit: string,
amount: string, // $currency:x.y
subject: string,): Promise<string> {
subject: string,
): Promise<string> {
const stdout = await sh(
this.globalTestState,
"libeufin-sandbox-maketransfer",
@ -428,7 +436,7 @@ export class LibeufinCli {
LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl,
LIBEUFIN_SANDBOX_USERNAME: "admin",
LIBEUFIN_SANDBOX_PASSWORD: "secret",
}
};
}
async checkSandbox(): Promise<void> {
@ -436,7 +444,7 @@ export class LibeufinCli {
this.globalTestState,
"libeufin-cli-checksandbox",
"libeufin-cli sandbox check",
this.env()
this.env(),
);
}
@ -445,7 +453,7 @@ export class LibeufinCli {
this.globalTestState,
"libeufin-cli-createebicshost",
`libeufin-cli sandbox ebicshost create --host-id=${hostId}`,
this.env()
this.env(),
);
console.log(stdout);
}
@ -460,7 +468,7 @@ export class LibeufinCli {
` --host-id=${details.hostId}` +
` --partner-id=${details.partnerId}` +
` --user-id=${details.userId}`,
this.env()
this.env(),
);
console.log(stdout);
}
@ -480,7 +488,7 @@ export class LibeufinCli {
` --ebics-host-id=${sd.hostId}` +
` --ebics-partner-id=${sd.partnerId}` +
` --ebics-user-id=${sd.userId}`,
this.env()
this.env(),
);
console.log(stdout);
}
@ -490,7 +498,7 @@ export class LibeufinCli {
this.globalTestState,
"libeufin-cli-generatetransactions",
`libeufin-cli sandbox bankaccount generate-transactions ${accountName}`,
this.env()
this.env(),
);
console.log(stdout);
}
@ -500,7 +508,7 @@ export class LibeufinCli {
this.globalTestState,
"libeufin-cli-showsandboxtransactions",
`libeufin-cli sandbox bankaccount transactions ${accountName}`,
this.env()
this.env(),
);
console.log(stdout);
}
@ -834,9 +842,12 @@ export async function launchLibeufinServices(
libeufinNexus,
nb.twgHistoryPermission,
);
break;
break;
case "anastasis":
await LibeufinNexusApi.createAnastasisFacade(libeufinNexus, nb.anastasisReq);
await LibeufinNexusApi.createAnastasisFacade(
libeufinNexus,
nb.anastasisReq,
);
}
}
}

View File

@ -96,7 +96,7 @@ export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
debtorName: "mock2",
amount: "1",
subject: "mock subject",
}
},
);
await LibeufinNexusApi.fetchTransactions(nexus, "local-mock");
let transactions = await LibeufinNexusApi.getAccountTransactions(
@ -106,4 +106,5 @@ export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
let el = findNexusPayment("mock subject", transactions.data);
t.assertTrue(el instanceof Object);
}
runLibeufinApiBankaccountTest.suites = ["libeufin"];

View File

@ -17,12 +17,7 @@
/**
* Imports.
*/
import {
AbsoluteTime,
ContractTerms,
Duration,
durationFromSpec,
} from "@gnu-taler/taler-util";
import { AbsoluteTime, ContractTerms, Duration } from "@gnu-taler/taler-util";
import {
WalletApiOperation,
HarnessExchangeBankAccount,
@ -42,7 +37,7 @@ import {
LibeufinNexusService,
LibeufinSandboxApi,
LibeufinSandboxService,
} from "../harness/libeufin";
} from "../harness/libeufin.js";
const exchangeIban = "DE71500105179674997361";
const customerIban = "DE84500105176881385584";

View File

@ -22,7 +22,6 @@ import { GlobalTestState } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironment,
withdrawViaBank,
makeTestPayment,
} from "../harness/helpers.js";
/**
@ -55,9 +54,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
const checkResp = await wallet.client.call(
WalletApiOperation.CheckPeerPushPayment,
{
contractPriv: resp.contractPriv,
exchangeBaseUrl: resp.exchangeBaseUrl,
pursePub: resp.pursePub,
talerUri: resp.talerUri,
},
);
@ -66,8 +63,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
const acceptResp = await wallet.client.call(
WalletApiOperation.AcceptPeerPushPayment,
{
exchangeBaseUrl: resp.exchangeBaseUrl,
pursePub: resp.pursePub,
peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
},
);

View File

@ -127,36 +127,6 @@ export interface ReserveBankInfo {
* Exchange payto URI that the bank will use to fund the reserve.
*/
exchangePaytoUri: string;
}
/**
* A reserve record as stored in the wallet's database.
*/
export interface ReserveRecord {
/**
* The reserve public key.
*/
reservePub: string;
/**
* The reserve private key.
*/
reservePriv: string;
/**
* The exchange base URL for the reserve.
*/
exchangeBaseUrl: string;
/**
* Currency of the reserve.
*/
currency: string;
/**
* Time when the reserve was created.
*/
timestampCreated: TalerProtocolTimestamp;
/**
* Time when the information about this reserve was posted to the bank.
@ -165,83 +135,14 @@ export interface ReserveRecord {
*
* Set to undefined if that hasn't happened yet.
*/
timestampReserveInfoPosted: TalerProtocolTimestamp | undefined;
timestampReserveInfoPosted?: TalerProtocolTimestamp;
/**
* Time when the reserve was confirmed by the bank.
*
* Set to undefined if not confirmed yet.
*/
timestampBankConfirmed: TalerProtocolTimestamp | undefined;
/**
* Wire information (as payto URI) for the bank account that
* transferred funds for this reserve.
*/
senderWire?: string;
/**
* Amount that was sent by the user to fund the reserve.
*/
instructedAmount: AmountJson;
/**
* Extra state for when this is a withdrawal involving
* a Taler-integrated bank.
*/
bankInfo?: ReserveBankInfo;
/**
* Restrict withdrawals from this reserve to this age.
*/
restrictAge?: number;
/**
* Pre-allocated ID of the withdrawal group for the first withdrawal
* on this reserve.
*/
initialWithdrawalGroupId: string;
/**
* Did we start the first withdrawal for this reserve?
*
* We only report a pending withdrawal for the reserve before
* the first withdrawal has started.
*/
initialWithdrawalStarted: boolean;
/**
* Initial denomination selection, stored here so that
* we can show this information in the transactions/balances
* before we have a withdrawal group.
*/
initialDenomSel: DenomSelectionState;
/**
* Current status of the reserve.
*/
reserveStatus: ReserveRecordStatus;
/**
* Is there any work to be done for this reserve?
*
* Technically redundant, since the reserveStatus would indicate this.
* However, we use the operationStatus for DB indexing of pending operations.
*/
operationStatus: OperationStatus;
/**
* Retry info, in case the reserve needs to be processed again
* later, either due to an error or because the wallet needs to
* wait for something.
*/
retryInfo: RetryInfo | undefined;
/**
* Last error that happened in a reserve operation
* (either talking to the bank or the exchange).
*/
lastError: TalerErrorDetail | undefined;
timestampBankConfirmed?: TalerProtocolTimestamp;
}
/**
@ -514,6 +415,11 @@ export interface ExchangeDetailsPointer {
updateClock: TalerProtocolTimestamp;
}
export interface MergeReserveInfo {
reservePub: string;
reservePriv: string;
}
/**
* Exchange record as stored in the wallet's database.
*/
@ -568,7 +474,7 @@ export interface ExchangeRecord {
* Public key of the reserve that we're currently using for
* receiving P2P payments.
*/
currentMergeReservePub?: string;
currentMergeReserveInfo?: MergeReserveInfo;
}
/**
@ -1373,6 +1279,7 @@ export interface WithdrawalGroupRecord {
/**
* Secret seed used to derive planchets.
* Stored since planchets are created lazily.
*/
secretSeed: string;
@ -1381,6 +1288,11 @@ export interface WithdrawalGroupRecord {
*/
reservePub: string;
/**
* The reserve private key.
*/
reservePriv: string;
/**
* The exchange base URL that we're withdrawing from.
* (Redundantly stored, as the reserve record also has this info.)
@ -1395,8 +1307,6 @@ export interface WithdrawalGroupRecord {
/**
* When was the withdrawal operation completed?
*
* FIXME: We should probably drop this and introduce an OperationStatus field.
*/
timestampFinish?: TalerProtocolTimestamp;
@ -1406,6 +1316,33 @@ export interface WithdrawalGroupRecord {
*/
operationStatus: OperationStatus;
/**
* Current status of the reserve.
*/
reserveStatus: ReserveRecordStatus;
/**
* Amount that was sent by the user to fund the reserve.
*/
instructedAmount: AmountJson;
/**
* Wire information (as payto URI) for the bank account that
* transferred funds for this reserve.
*/
senderWire?: string;
/**
* Restrict withdrawals from this reserve to this age.
*/
restrictAge?: number;
/**
* Extra state for when this is a withdrawal involving
* a Taler-integrated bank.
*/
bankInfo?: ReserveBankInfo;
/**
* Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session).
@ -1730,9 +1667,11 @@ export interface PeerPushPaymentInitiationRecord {
/**
* Record for a push P2P payment that this wallet was offered.
*
* Primary key: (exchangeBaseUrl, pursePub)
* Unique: (exchangeBaseUrl, pursePub)
*/
export interface PeerPushPaymentIncomingRecord {
peerPushPaymentIncomingId: string;
exchangeBaseUrl: string;
pursePub: string;
@ -1828,16 +1767,6 @@ export const WalletStoresV1 = {
}),
{},
),
reserves: describeStore(
describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }),
{
byInitialWithdrawalGroupId: describeIndex(
"byInitialWithdrawalGroupId",
"initialWithdrawalGroupId",
),
byStatus: describeIndex("byStatus", "operationStatus"),
},
),
purchases: describeStore(
describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
{
@ -1926,9 +1855,14 @@ export const WalletStoresV1 = {
),
peerPushPaymentIncoming: describeStore(
describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", {
keyPath: ["exchangeBaseUrl", "pursePub"],
keyPath: "peerPushPaymentIncomingId",
}),
{},
{
byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
"exchangeBaseUrl",
"pursePub",
]),
},
),
};

View File

@ -53,7 +53,6 @@ export * from "./operations/exchanges.js";
export * from "./bank-api-client.js";
export * from "./operations/reserves.js";
export * from "./operations/withdraw.js";
export * from "./operations/refresh.js";

View File

@ -73,15 +73,6 @@ export interface MerchantOperations {
): Promise<MerchantInfo>;
}
export interface ReserveOperations {
processReserve(
ws: InternalWalletState,
reservePub: string,
options?: {
forceNow?: boolean;
},
): Promise<void>;
}
/**
* Interface for exchange-related operations.
@ -234,7 +225,6 @@ export interface InternalWalletState {
exchangeOps: ExchangeOperations;
recoupOps: RecoupOperations;
merchantOps: MerchantOperations;
reserveOps: ReserveOperations;
getDenomInfo(
ws: InternalWalletState,

View File

@ -88,7 +88,6 @@ export async function exportBackup(
backupProviders: x.backupProviders,
tips: x.tips,
recoupGroups: x.recoupGroups,
reserves: x.reserves,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
@ -128,29 +127,6 @@ export async function exportBackup(
});
});
await tx.reserves.iter().forEach((reserve) => {
const backupReserve: BackupReserve = {
initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
(x) => ({
count: x.count,
denom_pub_hash: x.denomPubHash,
}),
),
initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
instructed_amount: Amounts.stringify(reserve.instructedAmount),
reserve_priv: reserve.reservePriv,
timestamp_created: reserve.timestampCreated,
withdrawal_groups:
withdrawalGroupsByReserve[reserve.reservePub] ?? [],
// FIXME!
timestamp_last_activity: reserve.timestampCreated,
};
const backupReserves = (backupReservesByExchange[
reserve.exchangeBaseUrl
] ??= []);
backupReserves.push(backupReserve);
});
await tx.tips.iter().forEach((tip) => {
backupTips.push({
exchange_base_url: tip.exchangeBaseUrl,

View File

@ -236,7 +236,6 @@ export async function importBackup(
backupProviders: x.backupProviders,
tips: x.tips,
recoupGroups: x.recoupGroups,
reserves: x.reserves,
withdrawalGroups: x.withdrawalGroups,
tombstones: x.tombstones,
depositGroups: x.depositGroups,
@ -427,94 +426,98 @@ export async function importBackup(
}
}
for (const backupReserve of backupExchangeDetails.reserves) {
const reservePub =
cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
if (tombstoneSet.has(ts)) {
continue;
}
checkLogicInvariant(!!reservePub);
const existingReserve = await tx.reserves.get(reservePub);
const instructedAmount = Amounts.parseOrThrow(
backupReserve.instructed_amount,
);
if (!existingReserve) {
let bankInfo: ReserveBankInfo | undefined;
if (backupReserve.bank_info) {
bankInfo = {
exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
statusUrl: backupReserve.bank_info.status_url,
confirmUrl: backupReserve.bank_info.confirm_url,
};
}
await tx.reserves.put({
currency: instructedAmount.currency,
instructedAmount,
exchangeBaseUrl: backupExchangeDetails.base_url,
reservePub,
reservePriv: backupReserve.reserve_priv,
bankInfo,
timestampCreated: backupReserve.timestamp_created,
timestampBankConfirmed:
backupReserve.bank_info?.timestamp_bank_confirmed,
timestampReserveInfoPosted:
backupReserve.bank_info?.timestamp_reserve_info_posted,
senderWire: backupReserve.sender_wire,
retryInfo: RetryInfo.reset(),
lastError: undefined,
initialWithdrawalGroupId:
backupReserve.initial_withdrawal_group_id,
initialWithdrawalStarted:
backupReserve.withdrawal_groups.length > 0,
// FIXME!
reserveStatus: ReserveRecordStatus.QueryingStatus,
initialDenomSel: await getDenomSelStateFromBackup(
tx,
backupExchangeDetails.base_url,
backupReserve.initial_selected_denoms,
),
// FIXME!
operationStatus: OperationStatus.Pending,
});
}
for (const backupWg of backupReserve.withdrawal_groups) {
const ts = makeEventId(
TombstoneTag.DeleteWithdrawalGroup,
backupWg.withdrawal_group_id,
);
if (tombstoneSet.has(ts)) {
continue;
}
const existingWg = await tx.withdrawalGroups.get(
backupWg.withdrawal_group_id,
);
if (!existingWg) {
await tx.withdrawalGroups.put({
denomsSel: await getDenomSelStateFromBackup(
tx,
backupExchangeDetails.base_url,
backupWg.selected_denoms,
),
exchangeBaseUrl: backupExchangeDetails.base_url,
lastError: undefined,
rawWithdrawalAmount: Amounts.parseOrThrow(
backupWg.raw_withdrawal_amount,
),
reservePub,
retryInfo: RetryInfo.reset(),
secretSeed: backupWg.secret_seed,
timestampStart: backupWg.timestamp_created,
timestampFinish: backupWg.timestamp_finish,
withdrawalGroupId: backupWg.withdrawal_group_id,
denomSelUid: backupWg.selected_denoms_id,
operationStatus: backupWg.timestamp_finish
? OperationStatus.Finished
: OperationStatus.Pending,
});
}
}
}
// FIXME: import reserves with new schema
// for (const backupReserve of backupExchangeDetails.reserves) {
// const reservePub =
// cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
// const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
// if (tombstoneSet.has(ts)) {
// continue;
// }
// checkLogicInvariant(!!reservePub);
// const existingReserve = await tx.reserves.get(reservePub);
// const instructedAmount = Amounts.parseOrThrow(
// backupReserve.instructed_amount,
// );
// if (!existingReserve) {
// let bankInfo: ReserveBankInfo | undefined;
// if (backupReserve.bank_info) {
// bankInfo = {
// exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
// statusUrl: backupReserve.bank_info.status_url,
// confirmUrl: backupReserve.bank_info.confirm_url,
// };
// }
// await tx.reserves.put({
// currency: instructedAmount.currency,
// instructedAmount,
// exchangeBaseUrl: backupExchangeDetails.base_url,
// reservePub,
// reservePriv: backupReserve.reserve_priv,
// bankInfo,
// timestampCreated: backupReserve.timestamp_created,
// timestampBankConfirmed:
// backupReserve.bank_info?.timestamp_bank_confirmed,
// timestampReserveInfoPosted:
// backupReserve.bank_info?.timestamp_reserve_info_posted,
// senderWire: backupReserve.sender_wire,
// retryInfo: RetryInfo.reset(),
// lastError: undefined,
// initialWithdrawalGroupId:
// backupReserve.initial_withdrawal_group_id,
// initialWithdrawalStarted:
// backupReserve.withdrawal_groups.length > 0,
// // FIXME!
// reserveStatus: ReserveRecordStatus.QueryingStatus,
// initialDenomSel: await getDenomSelStateFromBackup(
// tx,
// backupExchangeDetails.base_url,
// backupReserve.initial_selected_denoms,
// ),
// // FIXME!
// operationStatus: OperationStatus.Pending,
// });
// }
// for (const backupWg of backupReserve.withdrawal_groups) {
// const ts = makeEventId(
// TombstoneTag.DeleteWithdrawalGroup,
// backupWg.withdrawal_group_id,
// );
// if (tombstoneSet.has(ts)) {
// continue;
// }
// const existingWg = await tx.withdrawalGroups.get(
// backupWg.withdrawal_group_id,
// );
// if (!existingWg) {
// await tx.withdrawalGroups.put({
// denomsSel: await getDenomSelStateFromBackup(
// tx,
// backupExchangeDetails.base_url,
// backupWg.selected_denoms,
// ),
// exchangeBaseUrl: backupExchangeDetails.base_url,
// lastError: undefined,
// rawWithdrawalAmount: Amounts.parseOrThrow(
// backupWg.raw_withdrawal_amount,
// ),
// reservePub,
// retryInfo: RetryInfo.reset(),
// secretSeed: backupWg.secret_seed,
// timestampStart: backupWg.timestamp_created,
// timestampFinish: backupWg.timestamp_finish,
// withdrawalGroupId: backupWg.withdrawal_group_id,
// denomSelUid: backupWg.selected_denoms_id,
// operationStatus: backupWg.timestamp_finish
// ? OperationStatus.Finished
// : OperationStatus.Pending,
// });
// }
// }
// }
}
for (const backupProposal of backupBlob.proposals) {
@ -920,10 +923,6 @@ export async function importBackup(
} else if (type === TombstoneTag.DeleteRefund) {
// Nothing required, will just prevent display
// in the transactions list
} else if (type === TombstoneTag.DeleteReserve) {
// FIXME: Once we also have account (=kyc) reserves,
// we need to check if the reserve is an account before deleting here
await tx.reserves.delete(rest[0]);
} else if (type === TombstoneTag.DeleteTip) {
await tx.tips.delete(rest[0]);
} else if (type === TombstoneTag.DeleteWithdrawalGroup) {

View File

@ -41,7 +41,6 @@ interface WalletBalance {
export async function getBalancesInsideTransaction(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{
reserves: typeof WalletStoresV1.reserves;
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
@ -65,17 +64,6 @@ export async function getBalancesInsideTransaction(
return balanceStore[currency];
};
// Initialize balance to zero, even if we didn't start withdrawing yet.
await tx.reserves.iter().forEach((r) => {
const b = initBalance(r.currency);
if (!r.initialWithdrawalStarted) {
b.pendingIncoming = Amounts.add(
b.pendingIncoming,
r.initialDenomSel.totalCoinValue,
).amount;
}
});
await tx.coins.iter().forEach((c) => {
// Only count fresh coins, as dormant coins will
// already be in a refresh session.
@ -154,7 +142,6 @@ export async function getBalances(
.mktx((x) => ({
coins: x.coins,
refreshGroups: x.refreshGroups,
reserves: x.reserves,
purchases: x.purchases,
withdrawalGroups: x.withdrawalGroups,
}))

View File

@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
(C) 2022 GNUnet e.V.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -30,35 +30,35 @@ import {
codecForAmountString,
codecForAny,
codecForExchangeGetContractResponse,
constructPayPushUri,
ContractTermsUtil,
decodeCrock,
Duration,
eddsaGetPublic,
encodeCrock,
ExchangePurseMergeRequest,
getRandomBytes,
InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse,
j2s,
Logger,
parsePayPushUri,
strcmp,
TalerProtocolTimestamp,
UnblindedSignature,
WalletAccountMergeFlags,
} from "@gnu-taler/taler-util";
import { url } from "inspector";
import {
CoinStatus,
MergeReserveInfo,
OperationStatus,
ReserveRecord,
ReserveRecordStatus,
WithdrawalGroupRecord,
} from "../db.js";
import {
checkSuccessResponseOrThrow,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/peer-to-peer.ts");
@ -265,6 +265,10 @@ export async function initiatePeerToPeerPush(
mergePriv: mergePair.priv,
pursePub: pursePair.pub,
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
talerUri: constructPayPushUri({
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
contractPriv: econtractResp.contractPriv,
}),
};
}
@ -281,26 +285,19 @@ export async function checkPeerPushPayment(
ws: InternalWalletState,
req: CheckPeerPushPaymentRequest,
): Promise<CheckPeerPushPaymentResponse> {
const getPurseUrl = new URL(
`purses/${req.pursePub}/deposit`,
req.exchangeBaseUrl,
);
// FIXME: Check if existing record exists!
const contractPub = encodeCrock(
eddsaGetPublic(decodeCrock(req.contractPriv)),
);
const uri = parsePayPushUri(req.talerUri);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
if (!uri) {
throw Error("got invalid taler://pay-push URI");
}
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const exchangeBaseUrl = uri.exchangeBaseUrl;
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(
`contracts/${contractPub}`,
req.exchangeBaseUrl,
);
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await ws.http.get(getContractUrl.href);
@ -309,22 +306,36 @@ export async function checkPeerPushPayment(
codecForExchangeGetContractResponse(),
);
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract,
contractPriv: req.contractPriv,
pursePub: req.pursePub,
contractPriv: contractPriv,
pursePub: pursePub,
});
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
await ws.db
.mktx((x) => ({
peerPushPaymentIncoming: x.peerPushPaymentIncoming,
}))
.runReadWrite(async (tx) => {
await tx.peerPushPaymentIncoming.add({
contractPriv: req.contractPriv,
exchangeBaseUrl: req.exchangeBaseUrl,
peerPushPaymentIncomingId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
mergePriv: dec.mergePriv,
pursePub: req.pursePub,
pursePub: pursePub,
timestampAccepted: TalerProtocolTimestamp.now(),
contractTerms: dec.contractTerms,
});
@ -333,6 +344,7 @@ export async function checkPeerPushPayment(
return {
amount: purseStatus.balance,
contractTerms: dec.contractTerms,
peerPushPaymentIncomingId,
};
}
@ -343,9 +355,9 @@ export function talerPaytoFromExchangeReserve(
const url = new URL(exchangeBaseUrl);
let proto: string;
if (url.protocol === "http:") {
proto = "taler+http";
proto = "taler-reserve-http";
} else if (url.protocol === "https:") {
proto = "taler";
proto = "taler-reserve";
} else {
throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
}
@ -365,69 +377,45 @@ export async function acceptPeerPushPayment(
const peerInc = await ws.db
.mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
.runReadOnly(async (tx) => {
return tx.peerPushPaymentIncoming.get([
req.exchangeBaseUrl,
req.pursePub,
]);
return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId);
});
if (!peerInc) {
throw Error("can't accept unknown incoming p2p push payment");
throw Error(
`can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`,
);
}
const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
// We have to create the key pair outside of the transaction,
// We have to eagerly create the key pair outside of the transaction,
// due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
const reserve: ReserveRecord | undefined = await ws.db
const mergeReserveInfo: MergeReserveInfo = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
reserves: x.reserves,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReservePub) {
return await tx.reserves.get(ex.currentMergeReservePub);
if (ex.currentMergeReserveInfo) {
return ex.currentMergeReserveInfo;
}
const rec: ReserveRecord = {
exchangeBaseUrl: req.exchangeBaseUrl,
// FIXME: field will be removed in the future, folded into withdrawal/p2p record.
reserveStatus: ReserveRecordStatus.Dormant,
timestampCreated: TalerProtocolTimestamp.now(),
instructedAmount: Amounts.getZero(amount.currency),
currency: amount.currency,
reservePub: newReservePair.pub,
await tx.exchanges.put(ex);
ex.currentMergeReserveInfo = {
reservePriv: newReservePair.priv,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
// FIXME!
initialDenomSel: undefined as any,
// FIXME!
initialWithdrawalGroupId: "",
initialWithdrawalStarted: false,
lastError: undefined,
operationStatus: OperationStatus.Pending,
retryInfo: undefined,
bankInfo: undefined,
restrictAge: undefined,
senderWire: undefined,
reservePub: newReservePair.pub,
};
await tx.reserves.put(rec);
return rec;
return ex.currentMergeReserveInfo;
});
if (!reserve) {
throw Error("can't create reserve");
}
const mergeTimestamp = TalerProtocolTimestamp.now();
const reservePayto = talerPaytoFromExchangeReserve(
reserve.exchangeBaseUrl,
reserve.reservePub,
peerInc.exchangeBaseUrl,
mergeReserveInfo.reservePub,
);
const sigRes = await ws.cryptoApi.signPurseMerge({
@ -442,12 +430,12 @@ export async function acceptPeerPushPayment(
purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
pursePub: peerInc.pursePub,
reservePayto,
reservePriv: reserve.reservePriv,
reservePriv: mergeReserveInfo.reservePriv,
});
const mergePurseUrl = new URL(
`purses/${req.pursePub}/merge`,
req.exchangeBaseUrl,
`purses/${peerInc.pursePub}/merge`,
peerInc.exchangeBaseUrl,
);
const mergeReq: ExchangePurseMergeRequest = {
@ -459,6 +447,17 @@ export async function acceptPeerPushPayment(
const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
logger.info(`merge request: ${j2s(mergeReq)}`);
const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny());
logger.info(`merge result: ${j2s(res)}`);
logger.info(`merge response: ${j2s(res)}`);
await internalCreateWithdrawalGroup(ws, {
amount,
exchangeBaseUrl: peerInc.exchangeBaseUrl,
reserveStatus: ReserveRecordStatus.QueryingStatus,
reserveKeyPair: {
priv: mergeReserveInfo.reservePriv,
pub: mergeReserveInfo.reservePub,
},
});
}

View File

@ -70,44 +70,6 @@ async function gatherExchangePending(
});
}
async function gatherReservePending(
tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>,
now: AbsoluteTime,
resp: PendingOperationsResponse,
): Promise<void> {
const reserves = await tx.reserves.indexes.byStatus.getAll(
OperationStatus.Pending,
);
for (const reserve of reserves) {
const reserveType = reserve.bankInfo
? ReserveType.TalerBankWithdraw
: ReserveType.Manual;
switch (reserve.reserveStatus) {
case ReserveRecordStatus.Dormant:
// nothing to report as pending
break;
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.QueryingStatus:
case ReserveRecordStatus.RegisteringBank: {
resp.pendingOperations.push({
type: PendingTaskType.Reserve,
givesLifeness: true,
timestampDue: reserve.retryInfo?.nextRetry ?? AbsoluteTime.now(),
stage: reserve.reserveStatus,
timestampCreated: reserve.timestampCreated,
reserveType,
reservePub: reserve.reservePub,
retryInfo: reserve.retryInfo,
});
break;
}
default:
// FIXME: report problem!
break;
}
}
}
async function gatherRefreshPending(
tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>,
now: AbsoluteTime,
@ -336,7 +298,6 @@ export async function getPendingOperations(
backupProviders: x.backupProviders,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
reserves: x.reserves,
refreshGroups: x.refreshGroups,
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
@ -352,7 +313,6 @@ export async function getPendingOperations(
pendingOperations: [],
};
await gatherExchangePending(tx, now, resp);
await gatherReservePending(tx, now, resp);
await gatherRefreshPending(tx, now, resp);
await gatherWithdrawalPending(tx, now, resp);
await gatherProposalPending(tx, now, resp);

View File

@ -26,28 +26,35 @@
*/
import {
Amounts,
codecForRecoupConfirmation, encodeCrock, getRandomBytes, j2s, Logger, NotificationType,
codecForRecoupConfirmation,
encodeCrock,
getRandomBytes,
j2s,
Logger,
NotificationType,
RefreshReason,
TalerErrorDetail,
TalerProtocolTimestamp, URL
TalerProtocolTimestamp,
URL,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
CoinSourceType,
CoinStatus, OperationStatus, RecoupGroupRecord,
CoinStatus,
OperationStatus,
RecoupGroupRecord,
RefreshCoinSource,
ReserveRecordStatus, WalletStoresV1, WithdrawCoinSource
ReserveRecordStatus,
WalletStoresV1,
WithdrawCoinSource,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
RetryInfo
} from "../util/retries.js";
import { RetryInfo } from "../util/retries.js";
import { guardOperationException } from "./common.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
import { getReserveRequestTimeout, processReserve } from "./reserves.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/recoup.ts");
@ -182,34 +189,24 @@ async function recoupWithdrawCoin(
cs: WithdrawCoinSource,
): Promise<void> {
const reservePub = cs.reservePub;
const d = await ws.db
const denomInfo = await ws.db
.mktx((x) => ({
reserves: x.reserves,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const reserve = await tx.reserves.get(reservePub);
if (!reserve) {
return;
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
reserve.exchangeBaseUrl,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denomInfo) {
return;
}
return { reserve, denomInfo };
return denomInfo;
});
if (!d) {
if (!denomInfo) {
// FIXME: We should at least emit some pending operation / warning for this?
return;
}
const { reserve, denomInfo } = d;
ws.notify({
type: NotificationType.RecoupStarted,
});
@ -224,9 +221,7 @@ async function recoupWithdrawCoin(
});
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
logger.trace(`requesting recoup via ${reqUrl.href}`);
const resp = await ws.http.postJson(reqUrl.href, recoupRequest, {
timeout: getReserveRequestTimeout(reserve),
});
const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
const recoupConfirmation = await readSuccessResponseJsonOrThrow(
resp,
codecForRecoupConfirmation(),
@ -244,7 +239,6 @@ async function recoupWithdrawCoin(
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
reserves: x.reserves,
recoupGroups: x.recoupGroups,
refreshGroups: x.refreshGroups,
}))
@ -260,18 +254,12 @@ async function recoupWithdrawCoin(
if (!updatedCoin) {
return;
}
const updatedReserve = await tx.reserves.get(reserve.reservePub);
if (!updatedReserve) {
return;
}
updatedCoin.status = CoinStatus.Dormant;
const currency = updatedCoin.currentAmount.currency;
updatedCoin.currentAmount = Amounts.getZero(currency);
updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
updatedReserve.retryInfo = RetryInfo.reset();
updatedReserve.operationStatus = OperationStatus.Pending;
await tx.coins.put(updatedCoin);
await tx.reserves.put(updatedReserve);
// FIXME: Actually withdraw here!
// await internalCreateWithdrawalGroup(ws, {...});
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
});
@ -341,7 +329,6 @@ async function recoupRefreshCoin(
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
reserves: x.reserves,
recoupGroups: x.recoupGroups,
refreshGroups: x.refreshGroups,
}))
@ -446,12 +433,6 @@ async function processRecoupGroupImpl(
reserveSet.add(coin.coinSource.reservePub);
}
}
for (const r of reserveSet.values()) {
processReserve(ws, r, { forceNow: true }).catch((e) => {
logger.error(`processing reserve ${r} after recoup failed`);
});
}
}
export async function createRecoupGroup(

View File

@ -1,843 +0,0 @@
/*
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 {
AbsoluteTime,
AcceptWithdrawalResponse,
addPaytoQueryParams,
Amounts,
canonicalizeBaseUrl,
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForWithdrawOperationStatusResponse,
CreateReserveRequest,
CreateReserveResponse,
Duration,
durationMax,
durationMin,
encodeCrock,
ForcedDenomSel,
getRandomBytes,
j2s,
Logger,
NotificationType,
randomBytes,
TalerErrorCode,
TalerErrorDetail,
URL,
} from "@gnu-taler/taler-util";
import {
DenomSelectionState,
OperationStatus,
ReserveBankInfo,
ReserveRecord,
ReserveRecordStatus,
WalletStoresV1,
WithdrawalGroupRecord,
} from "../db.js";
import { TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import {
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { RetryInfo } from "../util/retries.js";
import { guardOperationException } from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl,
} from "./exchanges.js";
import {
getBankWithdrawalInfo,
getCandidateWithdrawalDenoms,
processWithdrawGroup,
selectForcedWithdrawalDenominations,
selectWithdrawalDenominations,
updateWithdrawalDenoms,
} from "./withdraw.js";
const logger = new Logger("taler-wallet-core:reserves.ts");
/**
* Set up the reserve's retry timeout in preparation for
* processing the reserve.
*/
async function setupReserveRetry(
ws: InternalWalletState,
reservePub: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
if (options.reset) {
r.retryInfo = RetryInfo.reset();
} else {
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
delete r.lastError;
await tx.reserves.put(r);
});
}
/**
* Report an error that happened while processing the reserve.
*
* Logs the error via a notification and by storing it in the database.
*/
async function reportReserveError(
ws: InternalWalletState,
reservePub: string,
err: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
if (!r.retryInfo) {
logger.error(`got reserve error for inactive reserve (no retryInfo)`);
return;
}
r.lastError = err;
await tx.reserves.put(r);
});
ws.notify({
type: NotificationType.ReserveOperationError,
error: err,
});
}
/**
* 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 = AbsoluteTime.toTimestamp(AbsoluteTime.now());
const canonExchange = canonicalizeBaseUrl(req.exchange);
let reserveStatus;
if (req.bankWithdrawStatusUrl) {
reserveStatus = ReserveRecordStatus.RegisteringBank;
} else {
reserveStatus = ReserveRecordStatus.QueryingStatus;
}
let bankInfo: ReserveBankInfo | undefined;
if (req.bankWithdrawStatusUrl) {
if (!req.exchangePaytoUri) {
throw Error(
"Exchange payto URI must be specified for a bank-integrated withdrawal",
);
}
bankInfo = {
statusUrl: req.bankWithdrawStatusUrl,
exchangePaytoUri: req.exchangePaytoUri,
};
}
const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
await updateWithdrawalDenoms(ws, canonExchange);
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
let initialDenomSel: DenomSelectionState;
if (req.forcedDenomSel) {
logger.warn("using forced denom selection");
initialDenomSel = selectForcedWithdrawalDenominations(
req.amount,
denoms,
req.forcedDenomSel,
);
} else {
initialDenomSel = selectWithdrawalDenominations(req.amount, denoms);
}
const reserveRecord: ReserveRecord = {
instructedAmount: req.amount,
initialWithdrawalGroupId,
initialDenomSel,
initialWithdrawalStarted: false,
timestampCreated: now,
exchangeBaseUrl: canonExchange,
reservePriv: keypair.priv,
reservePub: keypair.pub,
senderWire: req.senderWire,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
bankInfo,
reserveStatus,
retryInfo: RetryInfo.reset(),
lastError: undefined,
currency: req.amount.currency,
operationStatus: OperationStatus.Pending,
restrictAge: req.restrictAge,
};
const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
const exchangeDetails = exchangeInfo.exchangeDetails;
if (!exchangeDetails) {
logger.trace(exchangeDetails);
throw Error("exchange not updated");
}
const { isAudited, isTrusted } = await getExchangeTrust(
ws,
exchangeInfo.exchange,
);
const resp = await ws.db
.mktx((x) => ({
exchangeTrust: x.exchangeTrust,
reserves: x.reserves,
bankWithdrawUris: x.bankWithdrawUris,
}))
.runReadWrite(async (tx) => {
// Check if we have already created a reserve for that bankWithdrawStatusUrl
if (reserveRecord.bankInfo?.statusUrl) {
const bwi = await tx.bankWithdrawUris.get(
reserveRecord.bankInfo.statusUrl,
);
if (bwi) {
const otherReserve = await tx.reserves.get(bwi.reservePub);
if (otherReserve) {
logger.trace(
"returning existing reserve for bankWithdrawStatusUri",
);
return {
exchange: otherReserve.exchangeBaseUrl,
reservePub: otherReserve.reservePub,
};
}
}
await tx.bankWithdrawUris.put({
reservePub: reserveRecord.reservePub,
talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
});
}
if (!isAudited && !isTrusted) {
await tx.exchangeTrust.put({
currency: reserveRecord.currency,
exchangeBaseUrl: reserveRecord.exchangeBaseUrl,
exchangeMasterPub: exchangeDetails.masterPublicKey,
uids: [encodeCrock(getRandomBytes(32))],
});
}
await tx.reserves.put(reserveRecord);
const r: CreateReserveResponse = {
exchange: canonExchange,
reservePub: keypair.pub,
};
return r;
});
if (reserveRecord.reservePub === resp.reservePub) {
// Only emit notification when a new reserve was created.
ws.notify({
type: NotificationType.ReserveCreated,
reservePub: reserveRecord.reservePub,
});
}
// Asynchronously process the reserve, but return
// to the caller already.
processReserve(ws, resp.reservePub, { forceNow: true }).catch((e) => {
logger.error("Processing reserve (after createReserve) failed:", e);
});
return resp;
}
/**
* Re-query the status of a reserve.
*/
export async function forceQueryReserve(
ws: InternalWalletState,
reservePub: string,
): Promise<void> {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const reserve = await tx.reserves.get(reservePub);
if (!reserve) {
return;
}
// Only force status query where it makes sense
switch (reserve.reserveStatus) {
case ReserveRecordStatus.Dormant:
reserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
reserve.operationStatus = OperationStatus.Pending;
reserve.retryInfo = RetryInfo.reset();
break;
default:
break;
}
await tx.reserves.put(reserve);
});
await processReserve(ws, reservePub, { forceNow: true });
}
/**
* First fetch information required 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,
options: {
forceNow?: boolean;
} = {},
): Promise<void> {
return ws.memoProcessReserve.memo(reservePub, async () => {
const onOpError = (err: TalerErrorDetail): Promise<void> =>
reportReserveError(ws, reservePub, err);
await guardOperationException(
() => processReserveImpl(ws, reservePub, options),
onOpError,
);
});
}
async function registerReserveWithBank(
ws: InternalWalletState,
reservePub: string,
): Promise<void> {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return await tx.reserves.get(reservePub);
});
switch (reserve?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankInfo = reserve.bankInfo;
if (!bankInfo) {
return;
}
const bankStatusUrl = bankInfo.statusUrl;
const httpResp = await ws.http.postJson(
bankStatusUrl,
{
reserve_pub: reservePub,
selected_exchange: bankInfo.exchangePaytoUri,
},
{
timeout: getReserveRequestTimeout(reserve),
},
);
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBankWithdrawalOperationPostResponse(),
);
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
r.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
AbsoluteTime.now(),
);
r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
r.operationStatus = OperationStatus.Pending;
if (!r.bankInfo) {
throw Error("invariant failed");
}
r.retryInfo = RetryInfo.reset();
await tx.reserves.put(r);
});
ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
return processReserveBankStatus(ws, reservePub);
}
export function getReserveRequestTimeout(r: ReserveRecord): Duration {
return durationMax(
{ d_ms: 60000 },
durationMin({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
);
}
async function processReserveBankStatus(
ws: InternalWalletState,
reservePub: string,
): Promise<void> {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
switch (reserve?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankStatusUrl = reserve.bankInfo?.statusUrl;
if (!bankStatusUrl) {
return;
}
const statusResp = await ws.http.get(bankStatusUrl, {
timeout: getReserveRequestTimeout(reserve),
});
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (status.aborted) {
logger.info("bank aborted the withdrawal");
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.BankAborted;
r.operationStatus = OperationStatus.Finished;
r.retryInfo = RetryInfo.reset();
await tx.reserves.put(r);
});
return;
}
// Bank still needs to know our reserve info
if (!status.selection_done) {
await registerReserveWithBank(ws, reservePub);
return await processReserveBankStatus(ws, reservePub);
}
// FIXME: Why do we do this?!
if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) {
await registerReserveWithBank(ws, reservePub);
return await processReserveBankStatus(ws, reservePub);
}
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
// Re-check reserve status within transaction
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
if (status.transfer_done) {
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.QueryingStatus;
r.operationStatus = OperationStatus.Pending;
r.retryInfo = RetryInfo.reset();
} else {
logger.info("Withdrawal operation not yet confirmed by bank");
if (r.bankInfo) {
r.bankInfo.confirmUrl = status.confirm_transfer_url;
}
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
await tx.reserves.put(r);
});
}
/**
* Update the information about a reserve that is stored in the wallet
* by querying the reserve's exchange.
*
* If the reserve have funds that are not allocated in a withdrawal group yet
* and are big enough to withdraw with available denominations,
* create a new withdrawal group for the remaining amount.
*/
async function updateReserve(
ws: InternalWalletState,
reservePub: string,
): Promise<{ ready: boolean }> {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
if (!reserve) {
throw Error("reserve not in db");
}
if (reserve.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
return { ready: true };
}
const reserveUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl);
reserveUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying reserve status via ${reserveUrl}`);
const resp = await ws.http.get(reserveUrl.href, {
timeout: getReserveRequestTimeout(reserve),
});
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForReserveStatus(),
);
if (result.isError) {
if (
resp.status === 404 &&
result.talerErrorResponse.code ===
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
) {
ws.notify({
type: NotificationType.ReserveNotYetFound,
reservePub,
});
return { ready: false };
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
logger.trace(`got reserve status ${j2s(result.response)}`);
const reserveInfo = result.response;
const reserveBalance = Amounts.parseOrThrow(reserveInfo.balance);
const currency = reserveBalance.currency;
await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl);
const denoms = await getCandidateWithdrawalDenoms(
ws,
reserve.exchangeBaseUrl,
);
const newWithdrawalGroup = await ws.db
.mktx((x) => ({
planchets: x.planchets,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
denominations: x.denominations,
}))
.runReadWrite(async (tx) => {
const newReserve = await tx.reserves.get(reserve.reservePub);
if (!newReserve) {
return;
}
let amountReservePlus = reserveBalance;
let amountReserveMinus = Amounts.getZero(currency);
// Subtract amount allocated in unfinished withdrawal groups
// for this reserve from the available amount.
await tx.withdrawalGroups.indexes.byReservePub
.iter(reservePub)
.forEachAsync(async (wg) => {
if (wg.timestampFinish) {
return;
}
await tx.planchets.indexes.byGroup
.iter(wg.withdrawalGroupId)
.forEachAsync(async (pr) => {
if (pr.withdrawalDone) {
return;
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
wg.exchangeBaseUrl,
pr.denomPubHash,
);
if (!denomInfo) {
logger.error(`no denom info found for ${pr.denomPubHash}`);
return;
}
amountReserveMinus = Amounts.add(
amountReserveMinus,
denomInfo.value,
denomInfo.feeWithdraw,
).amount;
});
});
const remainingAmount = Amounts.sub(
amountReservePlus,
amountReserveMinus,
).amount;
let withdrawalGroupId: string;
let denomSel: DenomSelectionState;
if (!newReserve.initialWithdrawalStarted) {
withdrawalGroupId = newReserve.initialWithdrawalGroupId;
newReserve.initialWithdrawalStarted = true;
denomSel = newReserve.initialDenomSel;
} else {
withdrawalGroupId = encodeCrock(randomBytes(32));
denomSel = selectWithdrawalDenominations(remainingAmount, denoms);
logger.trace(
`Remaining unclaimed amount in reseve is ${Amounts.stringify(
remainingAmount,
)} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`,
);
if (denomSel.selectedDenoms.length === 0) {
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
newReserve.operationStatus = OperationStatus.Finished;
delete newReserve.lastError;
delete newReserve.retryInfo;
await tx.reserves.put(newReserve);
return;
}
}
const withdrawalRecord: WithdrawalGroupRecord = {
withdrawalGroupId: withdrawalGroupId,
exchangeBaseUrl: reserve.exchangeBaseUrl,
reservePub: reserve.reservePub,
rawWithdrawalAmount: remainingAmount,
timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
retryInfo: RetryInfo.reset(),
lastError: undefined,
denomsSel: denomSel,
secretSeed: encodeCrock(getRandomBytes(64)),
denomSelUid: encodeCrock(getRandomBytes(32)),
operationStatus: OperationStatus.Pending,
};
delete newReserve.lastError;
delete newReserve.retryInfo;
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
newReserve.operationStatus = OperationStatus.Finished;
await tx.reserves.put(newReserve);
await tx.withdrawalGroups.put(withdrawalRecord);
return withdrawalRecord;
});
if (newWithdrawalGroup) {
logger.trace("processing new withdraw group");
ws.notify({
type: NotificationType.WithdrawGroupCreated,
withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
});
await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
}
return { ready: true };
}
async function processReserveImpl(
ws: InternalWalletState,
reservePub: string,
options: {
forceNow?: boolean;
} = {},
): Promise<void> {
const forceNow = options.forceNow ?? false;
await setupReserveRetry(ws, reservePub, { reset: forceNow });
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
if (!reserve) {
logger.error(
`not processing reserve: reserve ${reservePub} does not exist`,
);
return;
}
logger.trace(
`Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
);
switch (reserve.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
await processReserveBankStatus(ws, reservePub);
return await processReserveImpl(ws, reservePub, { forceNow: true });
case ReserveRecordStatus.QueryingStatus: {
const res = await updateReserve(ws, reservePub);
if (res.ready) {
return await processReserveImpl(ws, reservePub, { forceNow: true });
}
break;
}
case ReserveRecordStatus.Dormant:
// nothing to do
break;
case ReserveRecordStatus.WaitConfirmBank:
await processReserveBankStatus(ws, reservePub);
break;
case ReserveRecordStatus.BankAborted:
break;
default:
console.warn("unknown reserve record status:", reserve.reserveStatus);
assertUnreachable(reserve.reserveStatus);
break;
}
}
/**
* Create a reserve for a bank-integrated withdrawal from
* a taler://withdraw URI.
*/
export async function createTalerWithdrawReserve(
ws: InternalWalletState,
talerWithdrawUri: string,
selectedExchange: string,
options: {
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
} = {},
): Promise<AcceptWithdrawalResponse> {
await updateExchangeFromUrl(ws, selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
const exchangePaytoUri = await getExchangePaytoUri(
ws,
selectedExchange,
withdrawInfo.wireTypes,
);
const reserve = await createReserve(ws, {
amount: withdrawInfo.amount,
bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
exchange: selectedExchange,
senderWire: withdrawInfo.senderWire,
exchangePaytoUri: exchangePaytoUri,
restrictAge: options.restrictAge,
forcedDenomSel: options.forcedDenomSel,
});
// 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);
const processedReserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reserve.reservePub);
});
if (processedReserve?.reserveStatus === ReserveRecordStatus.BankAborted) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
{},
);
}
return {
reservePub: reserve.reservePub,
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
};
}
/**
* Get payto URIs that can be used to fund a reserve.
*/
export async function getFundingPaytoUris(
tx: GetReadOnlyAccess<{
reserves: typeof WalletStoresV1.reserves;
exchanges: typeof WalletStoresV1.exchanges;
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
}>,
reservePub: string,
): Promise<string[]> {
const r = await tx.reserves.get(reservePub);
if (!r) {
logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
return [];
}
const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
if (!exchangeDetails) {
logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
return [];
}
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
if (!plainPaytoUris) {
logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
return [];
}
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(r.instructedAmount),
message: `Taler Withdrawal ${r.reservePub}`,
}),
);
}

View File

@ -39,12 +39,12 @@ import {
URL,
PreparePayResultType,
} from "@gnu-taler/taler-util";
import { createTalerWithdrawReserve } from "./reserves.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { confirmPay, preparePayForUri } from "./pay.js";
import { getBalances } from "./balance.js";
import { applyRefund } from "./refund.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
const logger = new Logger("operations/testing.ts");
@ -104,14 +104,11 @@ export async function withdrawTestBalance(
amount,
);
await createTalerWithdrawReserve(
ws,
wresp.taler_withdraw_uri,
exchangeBaseUrl,
{
forcedDenomSel: req.forcedDenomSel,
},
);
await acceptWithdrawalFromUri(ws, {
talerWithdrawUri: wresp.taler_withdraw_uri,
selectedExchange: exchangeBaseUrl,
forcedDenomSel: req.forcedDenomSel,
});
await confirmBankWithdrawalUri(
ws.http,

View File

@ -36,7 +36,6 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import {
AbortStatus,
RefundState,
ReserveRecord,
ReserveRecordStatus,
WalletRefundItem,
} from "../db.js";
@ -44,9 +43,8 @@ import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import { processPurchasePay } from "./pay.js";
import { processRefreshGroup } from "./refresh.js";
import { getFundingPaytoUris } from "./reserves.js";
import { processTip } from "./tip.js";
import { processWithdrawGroup } from "./withdraw.js";
import { processWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts");
@ -127,7 +125,6 @@ export async function getTransactions(
proposals: x.proposals,
purchases: x.purchases,
refreshGroups: x.refreshGroups,
reserves: x.reserves,
tips: x.tips,
withdrawalGroups: x.withdrawalGroups,
planchets: x.planchets,
@ -151,24 +148,13 @@ export async function getTransactions(
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const r = await tx.reserves.get(wsr.reservePub);
if (!r) {
return;
}
let amountRaw: AmountJson | undefined = undefined;
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
amountRaw = r.instructedAmount;
} else {
amountRaw = wsr.denomsSel.totalWithdrawCost;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
if (wsr.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: r.timestampBankConfirmed ? true : false,
confirmed: wsr.bankInfo.timestampBankConfirmed ? true : false,
reservePub: wsr.reservePub,
bankConfirmationUrl: r.bankInfo.confirmUrl,
bankConfirmationUrl: wsr.bankInfo.confirmUrl,
};
} else {
const exchangeDetails = await getExchangeDetails(
@ -191,7 +177,7 @@ export async function getTransactions(
transactions.push({
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(amountRaw),
amountRaw: Amounts.stringify(wsr.rawWithdrawalAmount),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
@ -205,56 +191,6 @@ export async function getTransactions(
});
});
// Report pending withdrawals based on reserves that
// were created, but where the actual withdrawal group has
// not started yet.
tx.reserves.iter().forEachAsync(async (r) => {
if (shouldSkipCurrency(transactionsRequest, r.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (r.initialWithdrawalStarted) {
return;
}
if (r.reserveStatus === ReserveRecordStatus.BankAborted) {
return;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: false,
reservePub: r.reservePub,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
reservePub: r.reservePub,
exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
};
}
transactions.push({
type: TransactionType.Withdrawal,
amountRaw: Amounts.stringify(r.instructedAmount),
amountEffective: Amounts.stringify(
r.initialDenomSel.totalCoinValue,
),
exchangeBaseUrl: r.exchangeBaseUrl,
pending: true,
timestamp: r.timestampCreated,
withdrawalDetails: withdrawalDetails,
transactionId: makeEventId(
TransactionType.Withdrawal,
r.initialWithdrawalGroupId,
),
frozen: false,
...(r.lastError ? { error: r.lastError } : {}),
});
});
tx.depositGroups.iter().forEachAsync(async (dg) => {
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
@ -499,7 +435,7 @@ export async function retryTransaction(
}
case TransactionType.Withdrawal: {
const withdrawalGroupId = rest[0];
await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true });
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true });
break;
}
case TransactionType.Payment: {
@ -536,7 +472,6 @@ export async function deleteTransaction(
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
@ -550,17 +485,6 @@ export async function deleteTransaction(
});
return;
}
const reserveRecord: ReserveRecord | undefined =
await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
withdrawalGroupId,
);
if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
const reservePub = reserveRecord.reservePub;
await tx.reserves.delete(reservePub);
await tx.tombstones.put({
id: TombstoneTag.DeleteReserve + ":" + reservePub,
});
}
});
} else if (type === TransactionType.Payment) {
const proposalId = rest[0];

View File

@ -19,20 +19,29 @@
*/
import {
AbsoluteTime,
AcceptManualWithdrawalResult,
AcceptWithdrawalResponse,
addPaytoQueryParams,
AmountJson,
AmountLike,
Amounts,
AmountString,
BankWithdrawDetails,
canonicalizeBaseUrl,
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForTalerConfigResponse,
codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
DenomKeyType,
Duration,
durationFromSpec,
durationFromSpec, encodeCrock,
ExchangeListItem,
ExchangeWithdrawRequest,
ForcedDenomSel,
getRandomBytes,
j2s,
LibtoolVersion,
Logger,
NotificationType,
@ -45,8 +54,9 @@ import {
VersionMatchResult,
WithdrawBatchResponse,
WithdrawResponse,
WithdrawUriInfoResponse,
WithdrawUriInfoResponse
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
@ -58,26 +68,42 @@ import {
ExchangeRecord,
OperationStatus,
PlanchetRecord,
WithdrawalGroupRecord,
ReserveBankInfo,
ReserveRecordStatus,
WalletStoresV1,
WithdrawalGroupRecord
} from "../db.js";
import {
getErrorDetailFromException,
makeErrorDetail,
TalerError,
TalerError
} from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js";
import {
HttpRequestLibrary,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError
} from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import {
DbAccess,
GetReadOnlyAccess
} from "../util/query.js";
import { RetryInfo } from "../util/retries.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION
} from "../versions.js";
import { guardOperationException } from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl
} from "./exchanges.js";
/**
* Logger for this file.
@ -215,7 +241,7 @@ export function selectWithdrawalDenominations(
for (const d of denoms) {
let count = 0;
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
for (; ;) {
for (;;) {
if (Amounts.cmp(remaining, cost) < 0) {
break;
}
@ -410,47 +436,42 @@ async function processPlanchetGenerate(
return;
}
let ci = 0;
let denomPubHash: string | undefined;
let maybeDenomPubHash: string | undefined;
for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
const d = withdrawalGroup.denomsSel.selectedDenoms[di];
if (coinIdx >= ci && coinIdx < ci + d.count) {
denomPubHash = d.denomPubHash;
maybeDenomPubHash = d.denomPubHash;
break;
}
ci += d.count;
}
if (!denomPubHash) {
if (!maybeDenomPubHash) {
throw Error("invariant violated");
}
const denomPubHash = maybeDenomPubHash;
const { denom, reserve } = await ws.db
const denom = await ws.db
.mktx((x) => ({
reserves: x.reserves,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const denom = await tx.denominations.get([
return ws.getDenomInfo(
ws,
tx,
withdrawalGroup.exchangeBaseUrl,
denomPubHash!,
]);
if (!denom) {
throw Error("invariant violated");
}
const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
if (!reserve) {
throw Error("invariant violated");
}
return { denom, reserve };
denomPubHash,
);
});
checkDbInvariant(!!denom);
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw,
reservePriv: reserve.reservePriv,
reservePub: reserve.reservePub,
reservePriv: withdrawalGroup.reservePriv,
reservePub: withdrawalGroup.reservePub,
value: denom.value,
coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed,
restrictAge: reserve.restrictAge,
restrictAge: withdrawalGroup.restrictAge,
});
const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey,
@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin(
const planchetCoinPub = planchet.coinPub;
// Check if this is the first time that the whole
// withdrawal succeeded. If so, mark the withdrawal
// group as finished.
const firstSuccess = await ws.db
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets,
}))
.runReadWrite(async (tx) => {
@ -875,7 +898,8 @@ export async function updateWithdrawalDenoms(
denom.verificationStatus === DenominationVerificationStatus.Unverified
) {
logger.trace(
`Validating denomination (${current + 1}/${denominations.length
`Validating denomination (${current + 1}/${
denominations.length
}) signature of ${denom.denomPubHash}`,
);
let valid = false;
@ -960,7 +984,80 @@ async function reportWithdrawalError(
ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
}
export async function processWithdrawGroup(
/**
* Update the information about a reserve that is stored in the wallet
* by querying the reserve's exchange.
*
* If the reserve have funds that are not allocated in a withdrawal group yet
* and are big enough to withdraw with available denominations,
* create a new withdrawal group for the remaining amount.
*/
async function queryReserve(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<{ ready: boolean }> {
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
checkDbInvariant(!!withdrawalGroup);
if (withdrawalGroup.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
return { ready: true };
}
const reservePub = withdrawalGroup.reservePub;
const reserveUrl = new URL(
`reserves/${reservePub}`,
withdrawalGroup.exchangeBaseUrl,
);
reserveUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying reserve status via ${reserveUrl}`);
const resp = await ws.http.get(reserveUrl.href, {
timeout: getReserveRequestTimeout(withdrawalGroup),
});
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForReserveStatus(),
);
if (result.isError) {
if (
resp.status === 404 &&
result.talerErrorResponse.code ===
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
) {
ws.notify({
type: NotificationType.ReserveNotYetFound,
reservePub,
});
return { ready: false };
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
logger.trace(`got reserve status ${j2s(result.response)}`);
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
wg.reserveStatus = ReserveRecordStatus.Dormant;
await tx.withdrawalGroups.put(wg);
});
return { ready: true };
}
export async function processWithdrawalGroup(
ws: InternalWalletState,
withdrawalGroupId: string,
options: {
@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl(
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
});
if (!withdrawalGroup) {
// Withdrawal group doesn't exist yet, but reserve might exist
// (and reference the yet to be created withdrawal group)
const reservePub = await ws.db
.mktx((x) => ({ reserves: x.reserves }))
.runReadOnly(async (tx) => {
const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
withdrawalGroupId,
);
return r?.reservePub;
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
}
switch (withdrawalGroup.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
await processReserveBankStatus(ws, withdrawalGroupId);
return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
forceNow: true,
});
if (!reservePub) {
logger.warn(
"withdrawal group doesn't exist (and reserve doesn't exist either)",
);
case ReserveRecordStatus.QueryingStatus: {
const res = await queryReserve(ws, withdrawalGroupId);
if (res.ready) {
return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
forceNow: true,
});
}
return;
}
return await ws.reserveOps.processReserve(ws, reservePub, { forceNow });
case ReserveRecordStatus.WaitConfirmBank:
await processReserveBankStatus(ws, withdrawalGroupId);
return;
case ReserveRecordStatus.BankAborted:
// FIXME
return;
case ReserveRecordStatus.Dormant:
// We can try to withdraw, nothing needs to be done with the reserve.
break;
default:
logger.warn(
"unknown reserve record status:",
withdrawalGroup.reserveStatus,
);
assertUnreachable(withdrawalGroup.reserveStatus);
break;
}
await ws.exchangeOps.updateExchangeFromUrl(
@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl(
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets,
}))
.runReadWrite(async (tx) => {
@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo(
!versionMatch.compatible &&
versionMatch.currentCmp === -1
) {
console.warn(
logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
);
}
}
@ -1308,3 +1422,456 @@ export async function getWithdrawalDetailsForUri(
possibleExchanges: exchanges,
};
}
export async function getFundingPaytoUrisTx(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<string[]> {
return await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
}
/**
* Get payto URIs that can be used to fund a withdrawal operation.
*/
export async function getFundingPaytoUris(
tx: GetReadOnlyAccess<{
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
exchanges: typeof WalletStoresV1.exchanges;
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
}>,
withdrawalGroupId: string,
): Promise<string[]> {
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
checkDbInvariant(!!withdrawalGroup);
const exchangeDetails = await getExchangeDetails(
tx,
withdrawalGroup.exchangeBaseUrl,
);
if (!exchangeDetails) {
logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
return [];
}
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
if (!plainPaytoUris) {
logger.error(
`exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
);
return [];
}
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(withdrawalGroup.instructedAmount),
message: `Taler Withdrawal ${withdrawalGroup.reservePub}`,
}),
);
}
async function getWithdrawalGroupRecordTx(
db: DbAccess<typeof WalletStoresV1>,
req: {
withdrawalGroupId: string;
},
): Promise<WithdrawalGroupRecord | undefined> {
return await db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(req.withdrawalGroupId);
});
}
export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
return Duration.max(
{ d_ms: 60000 },
Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
);
}
async function registerReserveWithBank(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<void> {
const withdrawalGroup = await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadOnly(async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
});
switch (withdrawalGroup?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankInfo = withdrawalGroup.bankInfo;
if (!bankInfo) {
return;
}
const bankStatusUrl = bankInfo.statusUrl;
const httpResp = await ws.http.postJson(
bankStatusUrl,
{
reserve_pub: withdrawalGroup.reservePub,
selected_exchange: bankInfo.exchangePaytoUri,
},
{
timeout: getReserveRequestTimeout(withdrawalGroup),
},
);
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBankWithdrawalOperationPostResponse(),
);
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
if (!r.bankInfo) {
throw Error("invariant failed");
}
r.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
AbsoluteTime.now(),
);
r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
r.operationStatus = OperationStatus.Pending;
r.retryInfo = RetryInfo.reset();
await tx.withdrawalGroups.put(r);
});
ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
return processReserveBankStatus(ws, withdrawalGroupId);
}
async function processReserveBankStatus(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<void> {
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
switch (withdrawalGroup?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl;
if (!bankStatusUrl) {
return;
}
const statusResp = await ws.http.get(bankStatusUrl, {
timeout: getReserveRequestTimeout(withdrawalGroup),
});
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (status.aborted) {
logger.info("bank aborted the withdrawal");
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
if (!r.bankInfo) {
throw Error("invariant failed");
}
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.bankInfo.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.BankAborted;
r.operationStatus = OperationStatus.Finished;
r.retryInfo = RetryInfo.reset();
await tx.withdrawalGroups.put(r);
});
return;
}
// Bank still needs to know our reserve info
if (!status.selection_done) {
await registerReserveWithBank(ws, withdrawalGroupId);
return await processReserveBankStatus(ws, withdrawalGroupId);
}
// FIXME: Why do we do this?!
if (withdrawalGroup.reserveStatus === ReserveRecordStatus.RegisteringBank) {
await registerReserveWithBank(ws, withdrawalGroupId);
return await processReserveBankStatus(ws, withdrawalGroupId);
}
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
// Re-check reserve status within transaction
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
if (status.transfer_done) {
logger.info("withdrawal: transfer confirmed by bank.");
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
if (!r.bankInfo) {
throw Error("invariant failed");
}
r.bankInfo.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.QueryingStatus;
r.operationStatus = OperationStatus.Pending;
r.retryInfo = RetryInfo.reset();
} else {
logger.info("withdrawal: transfer not yet confirmed by bank");
if (r.bankInfo) {
r.bankInfo.confirmUrl = status.confirm_transfer_url;
}
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
await tx.withdrawalGroups.put(r);
});
}
export async function internalCreateWithdrawalGroup(
ws: InternalWalletState,
args: {
reserveStatus: ReserveRecordStatus;
amount: AmountJson;
bankInfo?: ReserveBankInfo;
exchangeBaseUrl: string;
forcedDenomSel?: ForcedDenomSel;
reserveKeyPair?: EddsaKeypair;
restrictAge?: number;
},
): Promise<WithdrawalGroupRecord> {
const reserveKeyPair =
args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
const secretSeed = encodeCrock(getRandomBytes(32));
const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const amount = args.amount;
await updateWithdrawalDenoms(ws, canonExchange);
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
let initialDenomSel: DenomSelectionState;
const denomSelUid = encodeCrock(getRandomBytes(16));
if (args.forcedDenomSel) {
logger.warn("using forced denom selection");
initialDenomSel = selectForcedWithdrawalDenominations(
amount,
denoms,
args.forcedDenomSel,
);
} else {
initialDenomSel = selectWithdrawalDenominations(amount, denoms);
}
const withdrawalGroup: WithdrawalGroupRecord = {
denomSelUid,
denomsSel: initialDenomSel,
exchangeBaseUrl: canonExchange,
instructedAmount: amount,
timestampStart: now,
lastError: undefined,
operationStatus: OperationStatus.Pending,
rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
secretSeed,
reservePriv: reserveKeyPair.priv,
reservePub: reserveKeyPair.pub,
reserveStatus: args.reserveStatus,
retryInfo: RetryInfo.reset(),
withdrawalGroupId,
bankInfo: args.bankInfo,
restrictAge: args.restrictAge,
senderWire: undefined,
timestampFinish: undefined,
};
const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
const exchangeDetails = exchangeInfo.exchangeDetails;
if (!exchangeDetails) {
logger.trace(exchangeDetails);
throw Error("exchange not updated");
}
const { isAudited, isTrusted } = await getExchangeTrust(
ws,
exchangeInfo.exchange,
);
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
exchangeTrust: x.exchangeTrust,
}))
.runReadWrite(async (tx) => {
await tx.withdrawalGroups.add(withdrawalGroup);
if (!isAudited && !isTrusted) {
await tx.exchangeTrust.put({
currency: amount.currency,
exchangeBaseUrl: canonExchange,
exchangeMasterPub: exchangeDetails.masterPublicKey,
uids: [encodeCrock(getRandomBytes(32))],
});
}
});
return withdrawalGroup;
}
export async function acceptWithdrawalFromUri(
ws: InternalWalletState,
req: {
talerWithdrawUri: string;
selectedExchange: string;
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
},
): Promise<AcceptWithdrawalResponse> {
await updateExchangeFromUrl(ws, req.selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(
ws.http,
req.talerWithdrawUri,
);
const exchangePaytoUri = await getExchangePaytoUri(
ws,
req.selectedExchange,
withdrawInfo.wireTypes,
);
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
amount: withdrawInfo.amount,
exchangeBaseUrl: req.selectedExchange,
forcedDenomSel: req.forcedDenomSel,
reserveStatus: ReserveRecordStatus.RegisteringBank,
bankInfo: {
exchangePaytoUri,
statusUrl: withdrawInfo.extractedStatusUrl,
confirmUrl: withdrawInfo.confirmTransferUrl,
},
});
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
// 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, withdrawalGroupId);
const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
if (
processedWithdrawalGroup?.reserveStatus === ReserveRecordStatus.BankAborted
) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
{},
);
}
// Start withdrawal in the background.
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
(err) => {
logger.error("Processing withdrawal (after creation) failed:", err);
},
);
return {
reservePub: withdrawalGroup.reservePub,
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
};
}
/**
* Create a manual withdrawal operation.
*
* Adds the corresponding exchange as a trusted exchange if it is neither
* audited nor trusted already.
*
* Asynchronously starts the withdrawal.
*/
export async function createManualWithdrawal(
ws: InternalWalletState,
req: {
exchangeBaseUrl: string;
amount: AmountLike;
restrictAge?: number;
forcedDenomSel?: ForcedDenomSel;
},
): Promise<AcceptManualWithdrawalResult> {
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
amount: Amounts.jsonifyAmount(req.amount),
exchangeBaseUrl: req.exchangeBaseUrl,
bankInfo: undefined,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
reserveStatus: ReserveRecordStatus.QueryingStatus,
});
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const exchangePaytoUris = await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
exchangeTrust: x.exchangeTrust,
}))
.runReadWrite(async (tx) => {
return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
});
// Start withdrawal in the background.
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
(err) => {
logger.error("Processing withdrawal (after creation) failed:", err);
},
);
return {
reservePub: withdrawalGroup.reservePub,
exchangePaytoUris: exchangePaytoUris,
};
}

View File

@ -40,7 +40,6 @@ export enum PendingTaskType {
ProposalChoice = "proposal-choice",
ProposalDownload = "proposal-download",
Refresh = "refresh",
Reserve = "reserve",
Recoup = "recoup",
RefundQuery = "refund-query",
TipPickup = "tip-pickup",
@ -60,7 +59,6 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
| PendingProposalDownloadTask
| PendingRefreshTask
| PendingRefundQueryTask
| PendingReserveTask
| PendingTipPickupTask
| PendingWithdrawTask
| PendingRecoupTask
@ -103,22 +101,6 @@ export enum ReserveType {
TalerBankWithdraw = "taler-bank-withdraw",
}
/**
* Status of processing a reserve.
*
* Does *not* include the withdrawal operation that might result
* from this.
*/
export interface PendingReserveTask {
type: PendingTaskType.Reserve;
retryInfo: RetryInfo | undefined;
stage: ReserveRecordStatus;
timestampCreated: TalerProtocolTimestamp;
reserveType: ReserveType;
reservePub: string;
bankWithdrawConfirmUrl?: string;
}
/**
* Status of an ongoing withdrawal operation.
*/

View File

@ -107,7 +107,6 @@ import {
MerchantOperations,
NotificationListener,
RecoupOperations,
ReserveOperations,
} from "./internal-wallet-state.js";
import { exportBackup } from "./operations/backup/export.js";
import {
@ -167,12 +166,6 @@ import {
prepareRefund,
processPurchaseQueryRefund,
} from "./operations/refund.js";
import {
createReserve,
createTalerWithdrawReserve,
getFundingPaytoUris,
processReserve,
} from "./operations/reserves.js";
import {
runIntegrationTest,
testPay,
@ -185,9 +178,12 @@ import {
retryTransaction,
} from "./operations/transactions.js";
import {
acceptWithdrawalFromUri,
createManualWithdrawal,
getExchangeWithdrawalInfo,
getFundingPaytoUrisTx,
getWithdrawalDetailsForUri,
processWithdrawGroup,
processWithdrawalGroup as processWithdrawalGroup,
} from "./operations/withdraw.js";
import {
PendingOperationsResponse,
@ -258,11 +254,8 @@ async function processOnePendingOperation(
case PendingTaskType.Refresh:
await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
break;
case PendingTaskType.Reserve:
await processReserve(ws, pending.reservePub, { forceNow });
break;
case PendingTaskType.Withdraw:
await processWithdrawGroup(ws, pending.withdrawalGroupId, { forceNow });
await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow });
break;
case PendingTaskType.ProposalDownload:
await processDownloadProposal(ws, pending.proposalId, { forceNow });
@ -464,40 +457,6 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> {
});
}
/**
* Create a reserve for a manual withdrawal.
*
* Adds the corresponding exchange as a trusted exchange if it is neither
* audited nor trusted already.
*/
async function acceptManualWithdrawal(
ws: InternalWalletState,
exchangeBaseUrl: string,
amount: AmountJson,
restrictAge?: number,
): Promise<AcceptManualWithdrawalResult> {
try {
const resp = await createReserve(ws, {
amount,
exchange: exchangeBaseUrl,
restrictAge,
});
const exchangePaytoUris = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
reserves: x.reserves,
}))
.runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
return {
reservePub: resp.reservePub,
exchangePaytoUris,
};
} finally {
ws.latch.trigger();
}
}
async function getExchangeTos(
ws: InternalWalletState,
exchangeBaseUrl: string,
@ -552,6 +511,10 @@ async function getExchangeTos(
};
}
/**
* List bank accounts known to the wallet from
* previous withdrawals.
*/
async function listKnownBankAccounts(
ws: InternalWalletState,
currency?: string,
@ -559,12 +522,13 @@ async function listKnownBankAccounts(
const accounts: PaytoUri[] = [];
await ws.db
.mktx((x) => ({
reserves: x.reserves,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadOnly(async (tx) => {
const reservesRecords = await tx.reserves.iter().toArray();
for (const r of reservesRecords) {
if (currency && currency !== r.currency) {
const withdrawalGroups = await tx.withdrawalGroups.iter().toArray();
for (const r of withdrawalGroups) {
const amount = r.rawWithdrawalAmount;
if (currency && currency !== amount.currency) {
continue;
}
const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined;
@ -614,31 +578,6 @@ async function getExchanges(
return { exchanges };
}
/**
* Inform the wallet that the status of a reserve has changed (e.g. due to a
* confirmation from the bank.).
*/
export async function handleNotifyReserve(
ws: InternalWalletState,
): Promise<void> {
const reserves = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.iter().toArray();
});
for (const r of reserves) {
if (r.reserveStatus === ReserveRecordStatus.WaitConfirmBank) {
try {
processReserve(ws, r.reservePub);
} catch (e) {
console.error(e);
}
}
}
}
async function setCoinSuspended(
ws: InternalWalletState,
coinPub: string,
@ -817,12 +756,11 @@ async function dispatchRequestInternal(
}
case "acceptManualWithdrawal": {
const req = codecForAcceptManualWithdrawalRequet().decode(payload);
const res = await acceptManualWithdrawal(
ws,
req.exchangeBaseUrl,
Amounts.parseOrThrow(req.amount),
req.restrictAge,
);
const res = await createManualWithdrawal(ws, {
amount: Amounts.parseOrThrow(req.amount),
exchangeBaseUrl: req.exchangeBaseUrl,
restrictAge: req.restrictAge,
});
return res;
}
case "getWithdrawalDetailsForAmount": {
@ -856,15 +794,12 @@ async function dispatchRequestInternal(
case "acceptBankIntegratedWithdrawal": {
const req =
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
return await createTalerWithdrawReserve(
ws,
req.talerWithdrawUri,
req.exchangeBaseUrl,
{
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
},
);
return await acceptWithdrawalFromUri(ws, {
selectedExchange: req.exchangeBaseUrl,
talerWithdrawUri: req.talerWithdrawUri,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
});
}
case "getExchangeTos": {
const req = codecForGetExchangeTosRequest().decode(payload);
@ -1033,7 +968,10 @@ async function dispatchRequestInternal(
req.exchange,
amount,
);
const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
const wres = await createManualWithdrawal(ws, {
amount: amount,
exchangeBaseUrl: req.exchange,
});
const paytoUri = details.paytoUris[0];
const pt = parsePaytoUri(paytoUri);
if (!pt) {
@ -1229,10 +1167,6 @@ class InternalWalletStateImpl implements InternalWalletState {
getMerchantInfo,
};
reserveOps: ReserveOperations = {
processReserve,
};
// FIXME: Use an LRU cache here.
private denomCache: Record<string, DenomInfo> = {};