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; mobile?: boolean;
} }
const VERSION: string = process.env.__VERSION__ || "dev"; // @ts-ignore
const GIT_HASH: string | undefined = process.env.__GIT_HASH__; 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; const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
export function Sidebar({ mobile }: Props): VNode { 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()!; const reducer = useAnastasisContext()!;
function saveSession(): void { function saveSession(): void {

View File

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

View File

@ -18,8 +18,13 @@
* Imports. * Imports.
*/ */
import test from "ava"; import test from "ava";
import { initNodePrng } from "./prng-node.js";
import { ContractTermsUtil } from "./contractTerms.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) => { test("contract terms canon hashing", (t) => {
const cReq = { const cReq = {
foo: 42, foo: 42,

View File

@ -381,7 +381,7 @@ test("taler age restriction crypto", async (t) => {
const pub2Ref = await Edx25519.getPublic(priv2); const pub2Ref = await Edx25519.getPublic(priv2);
t.is(pub2, pub2Ref); t.deepEqual(pub2, pub2Ref);
}); });
test("edx signing", async (t) => { test("edx signing", async (t) => {
@ -390,21 +390,13 @@ test("edx signing", async (t) => {
const msg = stringToBytes("hello world"); const msg = stringToBytes("hello world");
const sig = nacl.crypto_edx25519_sign_detached( const sig = nacl.crypto_edx25519_sign_detached(msg, priv1, pub1);
msg,
priv1,
pub1,
);
t.true( t.true(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
);
sig[0]++; sig[0]++;
t.false( t.false(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
);
}); });
test("edx test vector", async (t) => { 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)); 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( const pub2Prime = await Edx25519.publicKeyDerive(
decodeCrock(tv.pub1_edx), decodeCrock(tv.pub1_edx),
decodeCrock(tv.seed), decodeCrock(tv.seed),
); );
t.is(pub2Prime, decodeCrock(tv.pub2_edx)); t.deepEqual(pub2Prime, decodeCrock(tv.pub2_edx));
const priv2Prime = await Edx25519.privateKeyDerive( const priv2Prime = await Edx25519.privateKeyDerive(
decodeCrock(tv.priv1_edx), decodeCrock(tv.priv1_edx),
decodeCrock(tv.seed), 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, parseWithdrawUri,
parseRefundUri, parseRefundUri,
parseTipUri, parseTipUri,
parsePayPushUri,
constructPayPushUri,
} from "./taleruri.js"; } from "./taleruri.js";
test("taler pay url parsing: wrong scheme", (t) => { 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.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/");
t.is(r1.merchantTipId, "tipid"); 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 { canonicalizeBaseUrl } from "./helpers.js";
import { URLSearchParams } from "./url.js"; import { URLSearchParams, URL } from "./url.js";
export interface PayUriResult { export interface PayUriResult {
merchantBaseUrl: string; merchantBaseUrl: string;
@ -40,6 +40,11 @@ export interface TipUriResult {
merchantBaseUrl: string; merchantBaseUrl: string;
} }
export interface PayPushUriResult {
exchangeBaseUrl: string;
contractPriv: string;
}
/** /**
* Parse a taler[+http]://withdraw URI. * Parse a taler[+http]://withdraw URI.
* Return undefined if not passed a valid URI. * Return undefined if not passed a valid URI.
@ -79,6 +84,7 @@ export enum TalerUriType {
TalerTip = "taler-tip", TalerTip = "taler-tip",
TalerRefund = "taler-refund", TalerRefund = "taler-refund",
TalerNotifyReserve = "taler-notify-reserve", TalerNotifyReserve = "taler-notify-reserve",
TalerPayPush = "pay-push",
Unknown = "unknown", Unknown = "unknown",
} }
@ -111,6 +117,12 @@ export function classifyTalerUri(s: string): TalerUriType {
if (sl.startsWith("taler+http://withdraw/")) { if (sl.startsWith("taler+http://withdraw/")) {
return TalerUriType.TalerWithdraw; return TalerUriType.TalerWithdraw;
} }
if (sl.startsWith("taler://pay-push/")) {
return TalerUriType.TalerPayPush;
}
if (sl.startsWith("taler+http://pay-push/")) {
return TalerUriType.TalerPayPush;
}
if (sl.startsWith("taler://notify-reserve/")) { if (sl.startsWith("taler://notify-reserve/")) {
return TalerUriType.TalerNotifyReserve; 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. * Parse a taler[+http]://tip URI.
* Return undefined if not passed a valid URI. * Return undefined if not passed a valid URI.
@ -228,3 +262,24 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
orderId, 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 }; 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 { export function toIntegerYears(d: Duration): number {
if (typeof d.d_ms !== "number") { if (typeof d.d_ms !== "number") {
throw Error("infinite duration"); throw Error("infinite duration");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -127,36 +127,6 @@ export interface ReserveBankInfo {
* Exchange payto URI that the bank will use to fund the reserve. * Exchange payto URI that the bank will use to fund the reserve.
*/ */
exchangePaytoUri: string; 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. * 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. * Set to undefined if that hasn't happened yet.
*/ */
timestampReserveInfoPosted: TalerProtocolTimestamp | undefined; timestampReserveInfoPosted?: TalerProtocolTimestamp;
/** /**
* Time when the reserve was confirmed by the bank. * Time when the reserve was confirmed by the bank.
* *
* Set to undefined if not confirmed yet. * Set to undefined if not confirmed yet.
*/ */
timestampBankConfirmed: TalerProtocolTimestamp | undefined; timestampBankConfirmed?: TalerProtocolTimestamp;
/**
* 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;
} }
/** /**
@ -514,6 +415,11 @@ export interface ExchangeDetailsPointer {
updateClock: TalerProtocolTimestamp; updateClock: TalerProtocolTimestamp;
} }
export interface MergeReserveInfo {
reservePub: string;
reservePriv: string;
}
/** /**
* Exchange record as stored in the wallet's database. * 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 * Public key of the reserve that we're currently using for
* receiving P2P payments. * receiving P2P payments.
*/ */
currentMergeReservePub?: string; currentMergeReserveInfo?: MergeReserveInfo;
} }
/** /**
@ -1373,6 +1279,7 @@ export interface WithdrawalGroupRecord {
/** /**
* Secret seed used to derive planchets. * Secret seed used to derive planchets.
* Stored since planchets are created lazily.
*/ */
secretSeed: string; secretSeed: string;
@ -1381,6 +1288,11 @@ export interface WithdrawalGroupRecord {
*/ */
reservePub: string; reservePub: string;
/**
* The reserve private key.
*/
reservePriv: string;
/** /**
* The exchange base URL that we're withdrawing from. * The exchange base URL that we're withdrawing from.
* (Redundantly stored, as the reserve record also has this info.) * (Redundantly stored, as the reserve record also has this info.)
@ -1395,8 +1307,6 @@ export interface WithdrawalGroupRecord {
/** /**
* When was the withdrawal operation completed? * When was the withdrawal operation completed?
*
* FIXME: We should probably drop this and introduce an OperationStatus field.
*/ */
timestampFinish?: TalerProtocolTimestamp; timestampFinish?: TalerProtocolTimestamp;
@ -1406,6 +1316,33 @@ export interface WithdrawalGroupRecord {
*/ */
operationStatus: OperationStatus; 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 * Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session). * 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. * Record for a push P2P payment that this wallet was offered.
* *
* Primary key: (exchangeBaseUrl, pursePub) * Unique: (exchangeBaseUrl, pursePub)
*/ */
export interface PeerPushPaymentIncomingRecord { export interface PeerPushPaymentIncomingRecord {
peerPushPaymentIncomingId: string;
exchangeBaseUrl: string; exchangeBaseUrl: string;
pursePub: 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( purchases: describeStore(
describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }), describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
{ {
@ -1926,9 +1855,14 @@ export const WalletStoresV1 = {
), ),
peerPushPaymentIncoming: describeStore( peerPushPaymentIncoming: describeStore(
describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", { 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 "./bank-api-client.js";
export * from "./operations/reserves.js";
export * from "./operations/withdraw.js"; export * from "./operations/withdraw.js";
export * from "./operations/refresh.js"; export * from "./operations/refresh.js";

View File

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

View File

@ -88,7 +88,6 @@ export async function exportBackup(
backupProviders: x.backupProviders, backupProviders: x.backupProviders,
tips: x.tips, tips: x.tips,
recoupGroups: x.recoupGroups, recoupGroups: x.recoupGroups,
reserves: x.reserves,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
})) }))
.runReadWrite(async (tx) => { .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) => { await tx.tips.iter().forEach((tip) => {
backupTips.push({ backupTips.push({
exchange_base_url: tip.exchangeBaseUrl, exchange_base_url: tip.exchangeBaseUrl,

View File

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

View File

@ -41,7 +41,6 @@ interface WalletBalance {
export async function getBalancesInsideTransaction( export async function getBalancesInsideTransaction(
ws: InternalWalletState, ws: InternalWalletState,
tx: GetReadOnlyAccess<{ tx: GetReadOnlyAccess<{
reserves: typeof WalletStoresV1.reserves;
coins: typeof WalletStoresV1.coins; coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups; refreshGroups: typeof WalletStoresV1.refreshGroups;
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
@ -65,17 +64,6 @@ export async function getBalancesInsideTransaction(
return balanceStore[currency]; 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) => { await tx.coins.iter().forEach((c) => {
// Only count fresh coins, as dormant coins will // Only count fresh coins, as dormant coins will
// already be in a refresh session. // already be in a refresh session.
@ -154,7 +142,6 @@ export async function getBalances(
.mktx((x) => ({ .mktx((x) => ({
coins: x.coins, coins: x.coins,
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
reserves: x.reserves,
purchases: x.purchases, purchases: x.purchases,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
})) }))

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler 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 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 terms of the GNU General Public License as published by the Free Software
@ -30,35 +30,35 @@ import {
codecForAmountString, codecForAmountString,
codecForAny, codecForAny,
codecForExchangeGetContractResponse, codecForExchangeGetContractResponse,
constructPayPushUri,
ContractTermsUtil, ContractTermsUtil,
decodeCrock, decodeCrock,
Duration, Duration,
eddsaGetPublic, eddsaGetPublic,
encodeCrock, encodeCrock,
ExchangePurseMergeRequest, ExchangePurseMergeRequest,
getRandomBytes,
InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse, InitiatePeerPushPaymentResponse,
j2s, j2s,
Logger, Logger,
parsePayPushUri,
strcmp, strcmp,
TalerProtocolTimestamp, TalerProtocolTimestamp,
UnblindedSignature, UnblindedSignature,
WalletAccountMergeFlags, WalletAccountMergeFlags,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { url } from "inspector";
import { import {
CoinStatus, CoinStatus,
MergeReserveInfo,
OperationStatus, OperationStatus,
ReserveRecord,
ReserveRecordStatus, ReserveRecordStatus,
WithdrawalGroupRecord,
} from "../db.js"; } from "../db.js";
import { import { readSuccessResponseJsonOrThrow } from "../util/http.js";
checkSuccessResponseOrThrow,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/peer-to-peer.ts"); const logger = new Logger("operations/peer-to-peer.ts");
@ -265,6 +265,10 @@ export async function initiatePeerToPeerPush(
mergePriv: mergePair.priv, mergePriv: mergePair.priv,
pursePub: pursePair.pub, pursePub: pursePair.pub,
exchangeBaseUrl: coinSelRes.exchangeBaseUrl, exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
talerUri: constructPayPushUri({
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
contractPriv: econtractResp.contractPriv,
}),
}; };
} }
@ -281,26 +285,19 @@ export async function checkPeerPushPayment(
ws: InternalWalletState, ws: InternalWalletState,
req: CheckPeerPushPaymentRequest, req: CheckPeerPushPaymentRequest,
): Promise<CheckPeerPushPaymentResponse> { ): Promise<CheckPeerPushPaymentResponse> {
const getPurseUrl = new URL( // FIXME: Check if existing record exists!
`purses/${req.pursePub}/deposit`,
req.exchangeBaseUrl,
);
const contractPub = encodeCrock( const uri = parsePayPushUri(req.talerUri);
eddsaGetPublic(decodeCrock(req.contractPriv)),
);
const purseHttpResp = await ws.http.get(getPurseUrl.href); if (!uri) {
throw Error("got invalid taler://pay-push URI");
}
const purseStatus = await readSuccessResponseJsonOrThrow( const exchangeBaseUrl = uri.exchangeBaseUrl;
purseHttpResp, const contractPriv = uri.contractPriv;
codecForExchangePurseStatus(), const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
);
const getContractUrl = new URL( const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
`contracts/${contractPub}`,
req.exchangeBaseUrl,
);
const contractHttpResp = await ws.http.get(getContractUrl.href); const contractHttpResp = await ws.http.get(getContractUrl.href);
@ -309,22 +306,36 @@ export async function checkPeerPushPayment(
codecForExchangeGetContractResponse(), codecForExchangeGetContractResponse(),
); );
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForMerge({ const dec = await ws.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract, ciphertext: contractResp.econtract,
contractPriv: req.contractPriv, contractPriv: contractPriv,
pursePub: req.pursePub, 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 await ws.db
.mktx((x) => ({ .mktx((x) => ({
peerPushPaymentIncoming: x.peerPushPaymentIncoming, peerPushPaymentIncoming: x.peerPushPaymentIncoming,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
await tx.peerPushPaymentIncoming.add({ await tx.peerPushPaymentIncoming.add({
contractPriv: req.contractPriv, peerPushPaymentIncomingId,
exchangeBaseUrl: req.exchangeBaseUrl, contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
mergePriv: dec.mergePriv, mergePriv: dec.mergePriv,
pursePub: req.pursePub, pursePub: pursePub,
timestampAccepted: TalerProtocolTimestamp.now(), timestampAccepted: TalerProtocolTimestamp.now(),
contractTerms: dec.contractTerms, contractTerms: dec.contractTerms,
}); });
@ -333,6 +344,7 @@ export async function checkPeerPushPayment(
return { return {
amount: purseStatus.balance, amount: purseStatus.balance,
contractTerms: dec.contractTerms, contractTerms: dec.contractTerms,
peerPushPaymentIncomingId,
}; };
} }
@ -343,9 +355,9 @@ export function talerPaytoFromExchangeReserve(
const url = new URL(exchangeBaseUrl); const url = new URL(exchangeBaseUrl);
let proto: string; let proto: string;
if (url.protocol === "http:") { if (url.protocol === "http:") {
proto = "taler+http"; proto = "taler-reserve-http";
} else if (url.protocol === "https:") { } else if (url.protocol === "https:") {
proto = "taler"; proto = "taler-reserve";
} else { } else {
throw Error(`unsupported exchange base URL protocol (${url.protocol})`); throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
} }
@ -365,69 +377,45 @@ export async function acceptPeerPushPayment(
const peerInc = await ws.db const peerInc = await ws.db
.mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming })) .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
return tx.peerPushPaymentIncoming.get([ return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId);
req.exchangeBaseUrl,
req.pursePub,
]);
}); });
if (!peerInc) { 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); 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. // due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
const reserve: ReserveRecord | undefined = await ws.db const mergeReserveInfo: MergeReserveInfo = await ws.db
.mktx((x) => ({ .mktx((x) => ({
exchanges: x.exchanges, exchanges: x.exchanges,
reserves: x.reserves, withdrawalGroups: x.withdrawalGroups,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl); const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl);
checkDbInvariant(!!ex); checkDbInvariant(!!ex);
if (ex.currentMergeReservePub) { if (ex.currentMergeReserveInfo) {
return await tx.reserves.get(ex.currentMergeReservePub); return ex.currentMergeReserveInfo;
} }
const rec: ReserveRecord = { await tx.exchanges.put(ex);
exchangeBaseUrl: req.exchangeBaseUrl, ex.currentMergeReserveInfo = {
// 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,
reservePriv: newReservePair.priv, reservePriv: newReservePair.priv,
timestampBankConfirmed: undefined, reservePub: newReservePair.pub,
timestampReserveInfoPosted: undefined,
// FIXME!
initialDenomSel: undefined as any,
// FIXME!
initialWithdrawalGroupId: "",
initialWithdrawalStarted: false,
lastError: undefined,
operationStatus: OperationStatus.Pending,
retryInfo: undefined,
bankInfo: undefined,
restrictAge: undefined,
senderWire: undefined,
}; };
await tx.reserves.put(rec); return ex.currentMergeReserveInfo;
return rec;
}); });
if (!reserve) {
throw Error("can't create reserve");
}
const mergeTimestamp = TalerProtocolTimestamp.now(); const mergeTimestamp = TalerProtocolTimestamp.now();
const reservePayto = talerPaytoFromExchangeReserve( const reservePayto = talerPaytoFromExchangeReserve(
reserve.exchangeBaseUrl, peerInc.exchangeBaseUrl,
reserve.reservePub, mergeReserveInfo.reservePub,
); );
const sigRes = await ws.cryptoApi.signPurseMerge({ const sigRes = await ws.cryptoApi.signPurseMerge({
@ -442,12 +430,12 @@ export async function acceptPeerPushPayment(
purseFee: Amounts.stringify(Amounts.getZero(amount.currency)), purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
pursePub: peerInc.pursePub, pursePub: peerInc.pursePub,
reservePayto, reservePayto,
reservePriv: reserve.reservePriv, reservePriv: mergeReserveInfo.reservePriv,
}); });
const mergePurseUrl = new URL( const mergePurseUrl = new URL(
`purses/${req.pursePub}/merge`, `purses/${peerInc.pursePub}/merge`,
req.exchangeBaseUrl, peerInc.exchangeBaseUrl,
); );
const mergeReq: ExchangePurseMergeRequest = { const mergeReq: ExchangePurseMergeRequest = {
@ -459,6 +447,17 @@ export async function acceptPeerPushPayment(
const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq); const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
logger.info(`merge request: ${j2s(mergeReq)}`);
const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny()); 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( async function gatherRefreshPending(
tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>, tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>,
now: AbsoluteTime, now: AbsoluteTime,
@ -336,7 +298,6 @@ export async function getPendingOperations(
backupProviders: x.backupProviders, backupProviders: x.backupProviders,
exchanges: x.exchanges, exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails, exchangeDetails: x.exchangeDetails,
reserves: x.reserves,
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
coins: x.coins, coins: x.coins,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
@ -352,7 +313,6 @@ export async function getPendingOperations(
pendingOperations: [], pendingOperations: [],
}; };
await gatherExchangePending(tx, now, resp); await gatherExchangePending(tx, now, resp);
await gatherReservePending(tx, now, resp);
await gatherRefreshPending(tx, now, resp); await gatherRefreshPending(tx, now, resp);
await gatherWithdrawalPending(tx, now, resp); await gatherWithdrawalPending(tx, now, resp);
await gatherProposalPending(tx, now, resp); await gatherProposalPending(tx, now, resp);

View File

@ -26,28 +26,35 @@
*/ */
import { import {
Amounts, Amounts,
codecForRecoupConfirmation, encodeCrock, getRandomBytes, j2s, Logger, NotificationType, codecForRecoupConfirmation,
encodeCrock,
getRandomBytes,
j2s,
Logger,
NotificationType,
RefreshReason, RefreshReason,
TalerErrorDetail, TalerErrorDetail,
TalerProtocolTimestamp, URL TalerProtocolTimestamp,
URL,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
CoinStatus, OperationStatus, RecoupGroupRecord, CoinStatus,
OperationStatus,
RecoupGroupRecord,
RefreshCoinSource, RefreshCoinSource,
ReserveRecordStatus, WalletStoresV1, WithdrawCoinSource ReserveRecordStatus,
WalletStoresV1,
WithdrawCoinSource,
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { import { RetryInfo } from "../util/retries.js";
RetryInfo
} from "../util/retries.js";
import { guardOperationException } from "./common.js"; import { guardOperationException } from "./common.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.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"); const logger = new Logger("operations/recoup.ts");
@ -182,34 +189,24 @@ async function recoupWithdrawCoin(
cs: WithdrawCoinSource, cs: WithdrawCoinSource,
): Promise<void> { ): Promise<void> {
const reservePub = cs.reservePub; const reservePub = cs.reservePub;
const d = await ws.db const denomInfo = await ws.db
.mktx((x) => ({ .mktx((x) => ({
reserves: x.reserves,
denominations: x.denominations, denominations: x.denominations,
})) }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
const reserve = await tx.reserves.get(reservePub);
if (!reserve) {
return;
}
const denomInfo = await ws.getDenomInfo( const denomInfo = await ws.getDenomInfo(
ws, ws,
tx, tx,
reserve.exchangeBaseUrl, coin.exchangeBaseUrl,
coin.denomPubHash, coin.denomPubHash,
); );
if (!denomInfo) { return denomInfo;
return;
}
return { reserve, denomInfo };
}); });
if (!d) { if (!denomInfo) {
// FIXME: We should at least emit some pending operation / warning for this? // FIXME: We should at least emit some pending operation / warning for this?
return; return;
} }
const { reserve, denomInfo } = d;
ws.notify({ ws.notify({
type: NotificationType.RecoupStarted, type: NotificationType.RecoupStarted,
}); });
@ -224,9 +221,7 @@ async function recoupWithdrawCoin(
}); });
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
logger.trace(`requesting recoup via ${reqUrl.href}`); logger.trace(`requesting recoup via ${reqUrl.href}`);
const resp = await ws.http.postJson(reqUrl.href, recoupRequest, { const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
timeout: getReserveRequestTimeout(reserve),
});
const recoupConfirmation = await readSuccessResponseJsonOrThrow( const recoupConfirmation = await readSuccessResponseJsonOrThrow(
resp, resp,
codecForRecoupConfirmation(), codecForRecoupConfirmation(),
@ -244,7 +239,6 @@ async function recoupWithdrawCoin(
.mktx((x) => ({ .mktx((x) => ({
coins: x.coins, coins: x.coins,
denominations: x.denominations, denominations: x.denominations,
reserves: x.reserves,
recoupGroups: x.recoupGroups, recoupGroups: x.recoupGroups,
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
})) }))
@ -260,18 +254,12 @@ async function recoupWithdrawCoin(
if (!updatedCoin) { if (!updatedCoin) {
return; return;
} }
const updatedReserve = await tx.reserves.get(reserve.reservePub);
if (!updatedReserve) {
return;
}
updatedCoin.status = CoinStatus.Dormant; updatedCoin.status = CoinStatus.Dormant;
const currency = updatedCoin.currentAmount.currency; const currency = updatedCoin.currentAmount.currency;
updatedCoin.currentAmount = Amounts.getZero(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.coins.put(updatedCoin);
await tx.reserves.put(updatedReserve); // FIXME: Actually withdraw here!
// await internalCreateWithdrawalGroup(ws, {...});
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
}); });
@ -341,7 +329,6 @@ async function recoupRefreshCoin(
.mktx((x) => ({ .mktx((x) => ({
coins: x.coins, coins: x.coins,
denominations: x.denominations, denominations: x.denominations,
reserves: x.reserves,
recoupGroups: x.recoupGroups, recoupGroups: x.recoupGroups,
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
})) }))
@ -446,12 +433,6 @@ async function processRecoupGroupImpl(
reserveSet.add(coin.coinSource.reservePub); 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( 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, URL,
PreparePayResultType, PreparePayResultType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { createTalerWithdrawReserve } from "./reserves.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { confirmPay, preparePayForUri } from "./pay.js"; import { confirmPay, preparePayForUri } from "./pay.js";
import { getBalances } from "./balance.js"; import { getBalances } from "./balance.js";
import { applyRefund } from "./refund.js"; import { applyRefund } from "./refund.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
const logger = new Logger("operations/testing.ts"); const logger = new Logger("operations/testing.ts");
@ -104,14 +104,11 @@ export async function withdrawTestBalance(
amount, amount,
); );
await createTalerWithdrawReserve( await acceptWithdrawalFromUri(ws, {
ws, talerWithdrawUri: wresp.taler_withdraw_uri,
wresp.taler_withdraw_uri, selectedExchange: exchangeBaseUrl,
exchangeBaseUrl, forcedDenomSel: req.forcedDenomSel,
{ });
forcedDenomSel: req.forcedDenomSel,
},
);
await confirmBankWithdrawalUri( await confirmBankWithdrawalUri(
ws.http, ws.http,

View File

@ -36,7 +36,6 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { import {
AbortStatus, AbortStatus,
RefundState, RefundState,
ReserveRecord,
ReserveRecordStatus, ReserveRecordStatus,
WalletRefundItem, WalletRefundItem,
} from "../db.js"; } from "../db.js";
@ -44,9 +43,8 @@ import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { processPurchasePay } from "./pay.js"; import { processPurchasePay } from "./pay.js";
import { processRefreshGroup } from "./refresh.js"; import { processRefreshGroup } from "./refresh.js";
import { getFundingPaytoUris } from "./reserves.js";
import { processTip } from "./tip.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"); const logger = new Logger("taler-wallet-core:transactions.ts");
@ -127,7 +125,6 @@ export async function getTransactions(
proposals: x.proposals, proposals: x.proposals,
purchases: x.purchases, purchases: x.purchases,
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
reserves: x.reserves,
tips: x.tips, tips: x.tips,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
planchets: x.planchets, planchets: x.planchets,
@ -151,24 +148,13 @@ export async function getTransactions(
if (shouldSkipSearch(transactionsRequest, [])) { if (shouldSkipSearch(transactionsRequest, [])) {
return; 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; let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) { if (wsr.bankInfo) {
withdrawalDetails = { withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi, type: WithdrawalType.TalerBankIntegrationApi,
confirmed: r.timestampBankConfirmed ? true : false, confirmed: wsr.bankInfo.timestampBankConfirmed ? true : false,
reservePub: wsr.reservePub, reservePub: wsr.reservePub,
bankConfirmationUrl: r.bankInfo.confirmUrl, bankConfirmationUrl: wsr.bankInfo.confirmUrl,
}; };
} else { } else {
const exchangeDetails = await getExchangeDetails( const exchangeDetails = await getExchangeDetails(
@ -191,7 +177,7 @@ export async function getTransactions(
transactions.push({ transactions.push({
type: TransactionType.Withdrawal, type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(amountRaw), amountRaw: Amounts.stringify(wsr.rawWithdrawalAmount),
withdrawalDetails, withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl, exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish, 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) => { tx.depositGroups.iter().forEachAsync(async (dg) => {
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) { if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
@ -499,7 +435,7 @@ export async function retryTransaction(
} }
case TransactionType.Withdrawal: { case TransactionType.Withdrawal: {
const withdrawalGroupId = rest[0]; const withdrawalGroupId = rest[0];
await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true }); await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true });
break; break;
} }
case TransactionType.Payment: { case TransactionType.Payment: {
@ -536,7 +472,6 @@ export async function deleteTransaction(
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
tombstones: x.tombstones, tombstones: x.tombstones,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -550,17 +485,6 @@ export async function deleteTransaction(
}); });
return; 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) { } else if (type === TransactionType.Payment) {
const proposalId = rest[0]; const proposalId = rest[0];

View File

@ -19,20 +19,29 @@
*/ */
import { import {
AbsoluteTime, AbsoluteTime,
AcceptManualWithdrawalResult,
AcceptWithdrawalResponse,
addPaytoQueryParams,
AmountJson, AmountJson,
AmountLike,
Amounts, Amounts,
AmountString, AmountString,
BankWithdrawDetails, BankWithdrawDetails,
canonicalizeBaseUrl,
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForTalerConfigResponse, codecForTalerConfigResponse,
codecForWithdrawBatchResponse, codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse, codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse, codecForWithdrawResponse,
DenomKeyType, DenomKeyType,
Duration, Duration,
durationFromSpec, durationFromSpec, encodeCrock,
ExchangeListItem, ExchangeListItem,
ExchangeWithdrawRequest, ExchangeWithdrawRequest,
ForcedDenomSel, ForcedDenomSel,
getRandomBytes,
j2s,
LibtoolVersion, LibtoolVersion,
Logger, Logger,
NotificationType, NotificationType,
@ -45,8 +54,9 @@ import {
VersionMatchResult, VersionMatchResult,
WithdrawBatchResponse, WithdrawBatchResponse,
WithdrawResponse, WithdrawResponse,
WithdrawUriInfoResponse, WithdrawUriInfoResponse
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import { import {
CoinRecord, CoinRecord,
CoinSourceType, CoinSourceType,
@ -58,26 +68,42 @@ import {
ExchangeRecord, ExchangeRecord,
OperationStatus, OperationStatus,
PlanchetRecord, PlanchetRecord,
WithdrawalGroupRecord, ReserveBankInfo,
ReserveRecordStatus,
WalletStoresV1,
WithdrawalGroupRecord
} from "../db.js"; } from "../db.js";
import { import {
getErrorDetailFromException, getErrorDetailFromException,
makeErrorDetail, makeErrorDetail,
TalerError, TalerError
} from "../errors.js"; } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js"; import { walletCoreDebugFlags } from "../util/debugFlags.js";
import { import {
HttpRequestLibrary, HttpRequestLibrary,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError
} from "../util/http.js"; } from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import {
DbAccess,
GetReadOnlyAccess
} from "../util/query.js";
import { RetryInfo } from "../util/retries.js"; import { RetryInfo } from "../util/retries.js";
import { import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION
} from "../versions.js"; } from "../versions.js";
import { guardOperationException } from "./common.js"; import { guardOperationException } from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl
} from "./exchanges.js";
/** /**
* Logger for this file. * Logger for this file.
@ -215,7 +241,7 @@ export function selectWithdrawalDenominations(
for (const d of denoms) { for (const d of denoms) {
let count = 0; let count = 0;
const cost = Amounts.add(d.value, d.feeWithdraw).amount; const cost = Amounts.add(d.value, d.feeWithdraw).amount;
for (; ;) { for (;;) {
if (Amounts.cmp(remaining, cost) < 0) { if (Amounts.cmp(remaining, cost) < 0) {
break; break;
} }
@ -410,47 +436,42 @@ async function processPlanchetGenerate(
return; return;
} }
let ci = 0; let ci = 0;
let denomPubHash: string | undefined; let maybeDenomPubHash: string | undefined;
for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) { for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
const d = withdrawalGroup.denomsSel.selectedDenoms[di]; const d = withdrawalGroup.denomsSel.selectedDenoms[di];
if (coinIdx >= ci && coinIdx < ci + d.count) { if (coinIdx >= ci && coinIdx < ci + d.count) {
denomPubHash = d.denomPubHash; maybeDenomPubHash = d.denomPubHash;
break; break;
} }
ci += d.count; ci += d.count;
} }
if (!denomPubHash) { if (!maybeDenomPubHash) {
throw Error("invariant violated"); throw Error("invariant violated");
} }
const denomPubHash = maybeDenomPubHash;
const { denom, reserve } = await ws.db const denom = await ws.db
.mktx((x) => ({ .mktx((x) => ({
reserves: x.reserves,
denominations: x.denominations, denominations: x.denominations,
})) }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
const denom = await tx.denominations.get([ return ws.getDenomInfo(
ws,
tx,
withdrawalGroup.exchangeBaseUrl, withdrawalGroup.exchangeBaseUrl,
denomPubHash!, denomPubHash,
]); );
if (!denom) {
throw Error("invariant violated");
}
const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
if (!reserve) {
throw Error("invariant violated");
}
return { denom, reserve };
}); });
checkDbInvariant(!!denom);
const r = await ws.cryptoApi.createPlanchet({ const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub, denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw, feeWithdraw: denom.feeWithdraw,
reservePriv: reserve.reservePriv, reservePriv: withdrawalGroup.reservePriv,
reservePub: reserve.reservePub, reservePub: withdrawalGroup.reservePub,
value: denom.value, value: denom.value,
coinIndex: coinIdx, coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed, secretSeed: withdrawalGroup.secretSeed,
restrictAge: reserve.restrictAge, restrictAge: withdrawalGroup.restrictAge,
}); });
const newPlanchet: PlanchetRecord = { const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey, blindingKey: r.blindingKey,
@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin(
const planchetCoinPub = planchet.coinPub; 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 const firstSuccess = await ws.db
.mktx((x) => ({ .mktx((x) => ({
coins: x.coins, coins: x.coins,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets, planchets: x.planchets,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -875,7 +898,8 @@ export async function updateWithdrawalDenoms(
denom.verificationStatus === DenominationVerificationStatus.Unverified denom.verificationStatus === DenominationVerificationStatus.Unverified
) { ) {
logger.trace( logger.trace(
`Validating denomination (${current + 1}/${denominations.length `Validating denomination (${current + 1}/${
denominations.length
}) signature of ${denom.denomPubHash}`, }) signature of ${denom.denomPubHash}`,
); );
let valid = false; let valid = false;
@ -960,7 +984,80 @@ async function reportWithdrawalError(
ws.notify({ type: NotificationType.WithdrawOperationError, error: err }); 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, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
options: { options: {
@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl(
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId); return tx.withdrawalGroups.get(withdrawalGroupId);
}); });
if (!withdrawalGroup) { if (!withdrawalGroup) {
// Withdrawal group doesn't exist yet, but reserve might exist throw Error(`withdrawal group ${withdrawalGroupId} not found`);
// (and reference the yet to be created withdrawal group) }
const reservePub = await ws.db
.mktx((x) => ({ reserves: x.reserves })) switch (withdrawalGroup.reserveStatus) {
.runReadOnly(async (tx) => { case ReserveRecordStatus.RegisteringBank:
const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get( await processReserveBankStatus(ws, withdrawalGroupId);
withdrawalGroupId, return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
); forceNow: true,
return r?.reservePub;
}); });
if (!reservePub) { case ReserveRecordStatus.QueryingStatus: {
logger.warn( const res = await queryReserve(ws, withdrawalGroupId);
"withdrawal group doesn't exist (and reserve doesn't exist either)", if (res.ready) {
); return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
forceNow: true,
});
}
return; 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( await ws.exchangeOps.updateExchangeFromUrl(
@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl(
.mktx((x) => ({ .mktx((x) => ({
coins: x.coins, coins: x.coins,
withdrawalGroups: x.withdrawalGroups, withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets, planchets: x.planchets,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo(
!versionMatch.compatible && !versionMatch.compatible &&
versionMatch.currentCmp === -1 versionMatch.currentCmp === -1
) { ) {
console.warn( logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + `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, 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", ProposalChoice = "proposal-choice",
ProposalDownload = "proposal-download", ProposalDownload = "proposal-download",
Refresh = "refresh", Refresh = "refresh",
Reserve = "reserve",
Recoup = "recoup", Recoup = "recoup",
RefundQuery = "refund-query", RefundQuery = "refund-query",
TipPickup = "tip-pickup", TipPickup = "tip-pickup",
@ -60,7 +59,6 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
| PendingProposalDownloadTask | PendingProposalDownloadTask
| PendingRefreshTask | PendingRefreshTask
| PendingRefundQueryTask | PendingRefundQueryTask
| PendingReserveTask
| PendingTipPickupTask | PendingTipPickupTask
| PendingWithdrawTask | PendingWithdrawTask
| PendingRecoupTask | PendingRecoupTask
@ -103,22 +101,6 @@ export enum ReserveType {
TalerBankWithdraw = "taler-bank-withdraw", 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. * Status of an ongoing withdrawal operation.
*/ */

View File

@ -107,7 +107,6 @@ import {
MerchantOperations, MerchantOperations,
NotificationListener, NotificationListener,
RecoupOperations, RecoupOperations,
ReserveOperations,
} from "./internal-wallet-state.js"; } from "./internal-wallet-state.js";
import { exportBackup } from "./operations/backup/export.js"; import { exportBackup } from "./operations/backup/export.js";
import { import {
@ -167,12 +166,6 @@ import {
prepareRefund, prepareRefund,
processPurchaseQueryRefund, processPurchaseQueryRefund,
} from "./operations/refund.js"; } from "./operations/refund.js";
import {
createReserve,
createTalerWithdrawReserve,
getFundingPaytoUris,
processReserve,
} from "./operations/reserves.js";
import { import {
runIntegrationTest, runIntegrationTest,
testPay, testPay,
@ -185,9 +178,12 @@ import {
retryTransaction, retryTransaction,
} from "./operations/transactions.js"; } from "./operations/transactions.js";
import { import {
acceptWithdrawalFromUri,
createManualWithdrawal,
getExchangeWithdrawalInfo, getExchangeWithdrawalInfo,
getFundingPaytoUrisTx,
getWithdrawalDetailsForUri, getWithdrawalDetailsForUri,
processWithdrawGroup, processWithdrawalGroup as processWithdrawalGroup,
} from "./operations/withdraw.js"; } from "./operations/withdraw.js";
import { import {
PendingOperationsResponse, PendingOperationsResponse,
@ -258,11 +254,8 @@ async function processOnePendingOperation(
case PendingTaskType.Refresh: case PendingTaskType.Refresh:
await processRefreshGroup(ws, pending.refreshGroupId, { forceNow }); await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
break; break;
case PendingTaskType.Reserve:
await processReserve(ws, pending.reservePub, { forceNow });
break;
case PendingTaskType.Withdraw: case PendingTaskType.Withdraw:
await processWithdrawGroup(ws, pending.withdrawalGroupId, { forceNow }); await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow });
break; break;
case PendingTaskType.ProposalDownload: case PendingTaskType.ProposalDownload:
await processDownloadProposal(ws, pending.proposalId, { forceNow }); 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( async function getExchangeTos(
ws: InternalWalletState, ws: InternalWalletState,
exchangeBaseUrl: string, exchangeBaseUrl: string,
@ -552,6 +511,10 @@ async function getExchangeTos(
}; };
} }
/**
* List bank accounts known to the wallet from
* previous withdrawals.
*/
async function listKnownBankAccounts( async function listKnownBankAccounts(
ws: InternalWalletState, ws: InternalWalletState,
currency?: string, currency?: string,
@ -559,12 +522,13 @@ async function listKnownBankAccounts(
const accounts: PaytoUri[] = []; const accounts: PaytoUri[] = [];
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
reserves: x.reserves, withdrawalGroups: x.withdrawalGroups,
})) }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
const reservesRecords = await tx.reserves.iter().toArray(); const withdrawalGroups = await tx.withdrawalGroups.iter().toArray();
for (const r of reservesRecords) { for (const r of withdrawalGroups) {
if (currency && currency !== r.currency) { const amount = r.rawWithdrawalAmount;
if (currency && currency !== amount.currency) {
continue; continue;
} }
const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined; const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined;
@ -614,31 +578,6 @@ async function getExchanges(
return { exchanges }; 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( async function setCoinSuspended(
ws: InternalWalletState, ws: InternalWalletState,
coinPub: string, coinPub: string,
@ -817,12 +756,11 @@ async function dispatchRequestInternal(
} }
case "acceptManualWithdrawal": { case "acceptManualWithdrawal": {
const req = codecForAcceptManualWithdrawalRequet().decode(payload); const req = codecForAcceptManualWithdrawalRequet().decode(payload);
const res = await acceptManualWithdrawal( const res = await createManualWithdrawal(ws, {
ws, amount: Amounts.parseOrThrow(req.amount),
req.exchangeBaseUrl, exchangeBaseUrl: req.exchangeBaseUrl,
Amounts.parseOrThrow(req.amount), restrictAge: req.restrictAge,
req.restrictAge, });
);
return res; return res;
} }
case "getWithdrawalDetailsForAmount": { case "getWithdrawalDetailsForAmount": {
@ -856,15 +794,12 @@ async function dispatchRequestInternal(
case "acceptBankIntegratedWithdrawal": { case "acceptBankIntegratedWithdrawal": {
const req = const req =
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
return await createTalerWithdrawReserve( return await acceptWithdrawalFromUri(ws, {
ws, selectedExchange: req.exchangeBaseUrl,
req.talerWithdrawUri, talerWithdrawUri: req.talerWithdrawUri,
req.exchangeBaseUrl, forcedDenomSel: req.forcedDenomSel,
{ restrictAge: req.restrictAge,
forcedDenomSel: req.forcedDenomSel, });
restrictAge: req.restrictAge,
},
);
} }
case "getExchangeTos": { case "getExchangeTos": {
const req = codecForGetExchangeTosRequest().decode(payload); const req = codecForGetExchangeTosRequest().decode(payload);
@ -1033,7 +968,10 @@ async function dispatchRequestInternal(
req.exchange, req.exchange,
amount, 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 paytoUri = details.paytoUris[0];
const pt = parsePaytoUri(paytoUri); const pt = parsePaytoUri(paytoUri);
if (!pt) { if (!pt) {
@ -1229,10 +1167,6 @@ class InternalWalletStateImpl implements InternalWalletState {
getMerchantInfo, getMerchantInfo,
}; };
reserveOps: ReserveOperations = {
processReserve,
};
// FIXME: Use an LRU cache here. // FIXME: Use an LRU cache here.
private denomCache: Record<string, DenomInfo> = {}; private denomCache: Record<string, DenomInfo> = {};