implement peer to peer push payments
This commit is contained in:
parent
fb8372dfbf
commit
ac8f116780
@ -28,15 +28,14 @@ interface Props {
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
const VERSION: string = process.env.__VERSION__ || "dev";
|
||||
const GIT_HASH: string | undefined = process.env.__GIT_HASH__;
|
||||
// @ts-ignore
|
||||
const maybeEnv = process?.env || {};
|
||||
|
||||
const VERSION: string = maybeEnv.__VERSION__ || "dev";
|
||||
const GIT_HASH: string | undefined = maybeEnv.__GIT_HASH__;
|
||||
const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
|
||||
|
||||
export function Sidebar({ mobile }: Props): VNode {
|
||||
// const config = useConfigContext();
|
||||
const config = { version: "none" };
|
||||
// FIXME: add replacement for __VERSION__ with the current version
|
||||
const process = { env: { __VERSION__: "0.0.0" } };
|
||||
const reducer = useAnastasisContext()!;
|
||||
|
||||
function saveSession(): void {
|
||||
|
@ -186,7 +186,7 @@ class UnionCodecBuilder<
|
||||
throw new DecodingError(
|
||||
`expected tag for ${objectDisplayName} at ${renderContext(
|
||||
c,
|
||||
)}.${discriminator}`,
|
||||
)}.${String(discriminator)}`,
|
||||
);
|
||||
}
|
||||
const alt = alternatives.get(d);
|
||||
@ -194,7 +194,7 @@ class UnionCodecBuilder<
|
||||
throw new DecodingError(
|
||||
`unknown tag for ${objectDisplayName} ${d} at ${renderContext(
|
||||
c,
|
||||
)}.${discriminator}`,
|
||||
)}.${String(discriminator)}`,
|
||||
);
|
||||
}
|
||||
const altDecoded = alt.codec.decode(x);
|
||||
|
@ -18,8 +18,13 @@
|
||||
* Imports.
|
||||
*/
|
||||
import test from "ava";
|
||||
import { initNodePrng } from "./prng-node.js";
|
||||
import { ContractTermsUtil } from "./contractTerms.js";
|
||||
|
||||
// Since we import nacl-fast directly (and not via index.node.ts), we need to
|
||||
// init the PRNG manually.
|
||||
initNodePrng();
|
||||
|
||||
test("contract terms canon hashing", (t) => {
|
||||
const cReq = {
|
||||
foo: 42,
|
||||
|
@ -381,7 +381,7 @@ test("taler age restriction crypto", async (t) => {
|
||||
|
||||
const pub2Ref = await Edx25519.getPublic(priv2);
|
||||
|
||||
t.is(pub2, pub2Ref);
|
||||
t.deepEqual(pub2, pub2Ref);
|
||||
});
|
||||
|
||||
test("edx signing", async (t) => {
|
||||
@ -390,21 +390,13 @@ test("edx signing", async (t) => {
|
||||
|
||||
const msg = stringToBytes("hello world");
|
||||
|
||||
const sig = nacl.crypto_edx25519_sign_detached(
|
||||
msg,
|
||||
priv1,
|
||||
pub1,
|
||||
);
|
||||
const sig = nacl.crypto_edx25519_sign_detached(msg, priv1, pub1);
|
||||
|
||||
t.true(
|
||||
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
|
||||
);
|
||||
t.true(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
|
||||
|
||||
sig[0]++;
|
||||
|
||||
t.false(
|
||||
nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
|
||||
);
|
||||
t.false(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
|
||||
});
|
||||
|
||||
test("edx test vector", async (t) => {
|
||||
@ -422,18 +414,18 @@ test("edx test vector", async (t) => {
|
||||
|
||||
{
|
||||
const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx));
|
||||
t.is(pub1Prime, decodeCrock(tv.pub1_edx));
|
||||
t.deepEqual(pub1Prime, decodeCrock(tv.pub1_edx));
|
||||
}
|
||||
|
||||
const pub2Prime = await Edx25519.publicKeyDerive(
|
||||
decodeCrock(tv.pub1_edx),
|
||||
decodeCrock(tv.seed),
|
||||
);
|
||||
t.is(pub2Prime, decodeCrock(tv.pub2_edx));
|
||||
t.deepEqual(pub2Prime, decodeCrock(tv.pub2_edx));
|
||||
|
||||
const priv2Prime = await Edx25519.privateKeyDerive(
|
||||
decodeCrock(tv.priv1_edx),
|
||||
decodeCrock(tv.seed),
|
||||
);
|
||||
t.is(priv2Prime, decodeCrock(tv.priv2_edx));
|
||||
t.deepEqual(priv2Prime, decodeCrock(tv.priv2_edx));
|
||||
});
|
||||
|
@ -20,6 +20,8 @@ import {
|
||||
parseWithdrawUri,
|
||||
parseRefundUri,
|
||||
parseTipUri,
|
||||
parsePayPushUri,
|
||||
constructPayPushUri,
|
||||
} from "./taleruri.js";
|
||||
|
||||
test("taler pay url parsing: wrong scheme", (t) => {
|
||||
@ -182,3 +184,44 @@ test("taler tip pickup uri with instance and prefix", (t) => {
|
||||
t.is(r1.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/");
|
||||
t.is(r1.merchantTipId, "tipid");
|
||||
});
|
||||
|
||||
test("taler peer to peer push URI", (t) => {
|
||||
const url1 = "taler://pay-push/exch.example.com/foo";
|
||||
const r1 = parsePayPushUri(url1);
|
||||
if (!r1) {
|
||||
t.fail();
|
||||
return;
|
||||
}
|
||||
t.is(r1.exchangeBaseUrl, "https://exch.example.com/");
|
||||
t.is(r1.contractPriv, "foo");
|
||||
});
|
||||
|
||||
test("taler peer to peer push URI (path)", (t) => {
|
||||
const url1 = "taler://pay-push/exch.example.com:123/bla/foo";
|
||||
const r1 = parsePayPushUri(url1);
|
||||
if (!r1) {
|
||||
t.fail();
|
||||
return;
|
||||
}
|
||||
t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/");
|
||||
t.is(r1.contractPriv, "foo");
|
||||
});
|
||||
|
||||
test("taler peer to peer push URI (http)", (t) => {
|
||||
const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo";
|
||||
const r1 = parsePayPushUri(url1);
|
||||
if (!r1) {
|
||||
t.fail();
|
||||
return;
|
||||
}
|
||||
t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/");
|
||||
t.is(r1.contractPriv, "foo");
|
||||
});
|
||||
|
||||
test("taler peer to peer push URI (construction)", (t) => {
|
||||
const url = constructPayPushUri({
|
||||
exchangeBaseUrl: "https://foo.example.com/bla/",
|
||||
contractPriv: "123",
|
||||
});
|
||||
t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123");
|
||||
});
|
||||
|
@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { canonicalizeBaseUrl } from "./helpers.js";
|
||||
import { URLSearchParams } from "./url.js";
|
||||
import { URLSearchParams, URL } from "./url.js";
|
||||
|
||||
export interface PayUriResult {
|
||||
merchantBaseUrl: string;
|
||||
@ -40,6 +40,11 @@ export interface TipUriResult {
|
||||
merchantBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface PayPushUriResult {
|
||||
exchangeBaseUrl: string;
|
||||
contractPriv: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a taler[+http]://withdraw URI.
|
||||
* Return undefined if not passed a valid URI.
|
||||
@ -79,6 +84,7 @@ export enum TalerUriType {
|
||||
TalerTip = "taler-tip",
|
||||
TalerRefund = "taler-refund",
|
||||
TalerNotifyReserve = "taler-notify-reserve",
|
||||
TalerPayPush = "pay-push",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
@ -111,6 +117,12 @@ export function classifyTalerUri(s: string): TalerUriType {
|
||||
if (sl.startsWith("taler+http://withdraw/")) {
|
||||
return TalerUriType.TalerWithdraw;
|
||||
}
|
||||
if (sl.startsWith("taler://pay-push/")) {
|
||||
return TalerUriType.TalerPayPush;
|
||||
}
|
||||
if (sl.startsWith("taler+http://pay-push/")) {
|
||||
return TalerUriType.TalerPayPush;
|
||||
}
|
||||
if (sl.startsWith("taler://notify-reserve/")) {
|
||||
return TalerUriType.TalerNotifyReserve;
|
||||
}
|
||||
@ -176,6 +188,28 @@ export function parsePayUri(s: string): PayUriResult | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePayPushUri(s: string): PayPushUriResult | undefined {
|
||||
const pi = parseProtoInfo(s, "pay-push");
|
||||
if (!pi) {
|
||||
return undefined;
|
||||
}
|
||||
const c = pi?.rest.split("?");
|
||||
const parts = c[0].split("/");
|
||||
if (parts.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
const host = parts[0].toLowerCase();
|
||||
const contractPriv = parts[parts.length - 1];
|
||||
const pathSegments = parts.slice(1, parts.length - 1);
|
||||
const p = [host, ...pathSegments].join("/");
|
||||
const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
|
||||
|
||||
return {
|
||||
exchangeBaseUrl,
|
||||
contractPriv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a taler[+http]://tip URI.
|
||||
* Return undefined if not passed a valid URI.
|
||||
@ -228,3 +262,24 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
|
||||
orderId,
|
||||
};
|
||||
}
|
||||
|
||||
export function constructPayPushUri(args: {
|
||||
exchangeBaseUrl: string;
|
||||
contractPriv: string;
|
||||
}): string {
|
||||
const url = new URL(args.exchangeBaseUrl);
|
||||
let proto: string;
|
||||
if (url.protocol === "https:") {
|
||||
proto = "taler";
|
||||
} else if (url.protocol === "http:") {
|
||||
proto = "taler+http";
|
||||
} else {
|
||||
throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`);
|
||||
}
|
||||
if (!url.pathname.endsWith("/")) {
|
||||
throw Error(
|
||||
`exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`,
|
||||
);
|
||||
}
|
||||
return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`;
|
||||
}
|
||||
|
@ -92,6 +92,14 @@ export namespace Duration {
|
||||
return { d_ms: deadline.t_ms - now.t_ms };
|
||||
}
|
||||
|
||||
export function max(d1: Duration, d2: Duration): Duration {
|
||||
return durationMax(d1, d2);
|
||||
}
|
||||
|
||||
export function min(d1: Duration, d2: Duration): Duration {
|
||||
return durationMin(d1, d2);
|
||||
}
|
||||
|
||||
export function toIntegerYears(d: Duration): number {
|
||||
if (typeof d.d_ms !== "number") {
|
||||
throw Error("infinite duration");
|
||||
|
@ -858,10 +858,11 @@ interface GetContractTermsDetailsRequest {
|
||||
proposalId: string;
|
||||
}
|
||||
|
||||
export const codecForGetContractTermsDetails = (): Codec<GetContractTermsDetailsRequest> =>
|
||||
buildCodecForObject<GetContractTermsDetailsRequest>()
|
||||
.property("proposalId", codecForString())
|
||||
.build("GetContractTermsDetails");
|
||||
export const codecForGetContractTermsDetails =
|
||||
(): Codec<GetContractTermsDetailsRequest> =>
|
||||
buildCodecForObject<GetContractTermsDetailsRequest>()
|
||||
.property("proposalId", codecForString())
|
||||
.build("GetContractTermsDetails");
|
||||
|
||||
export interface PreparePayRequest {
|
||||
talerPayUri: string;
|
||||
@ -1280,6 +1281,7 @@ export interface InitiatePeerPushPaymentResponse {
|
||||
pursePub: string;
|
||||
mergePriv: string;
|
||||
contractPriv: string;
|
||||
talerUri: string;
|
||||
}
|
||||
|
||||
export const codecForInitiatePeerPushPaymentRequest =
|
||||
@ -1290,32 +1292,30 @@ export const codecForInitiatePeerPushPaymentRequest =
|
||||
.build("InitiatePeerPushPaymentRequest");
|
||||
|
||||
export interface CheckPeerPushPaymentRequest {
|
||||
exchangeBaseUrl: string;
|
||||
pursePub: string;
|
||||
contractPriv: string;
|
||||
talerUri: string;
|
||||
}
|
||||
|
||||
export interface CheckPeerPushPaymentResponse {
|
||||
contractTerms: any;
|
||||
amount: AmountString;
|
||||
peerPushPaymentIncomingId: string;
|
||||
}
|
||||
|
||||
export const codecForCheckPeerPushPaymentRequest =
|
||||
(): Codec<CheckPeerPushPaymentRequest> =>
|
||||
buildCodecForObject<CheckPeerPushPaymentRequest>()
|
||||
.property("pursePub", codecForString())
|
||||
.property("contractPriv", codecForString())
|
||||
.property("exchangeBaseUrl", codecForString())
|
||||
.property("talerUri", codecForString())
|
||||
.build("CheckPeerPushPaymentRequest");
|
||||
|
||||
export interface AcceptPeerPushPaymentRequest {
|
||||
exchangeBaseUrl: string;
|
||||
pursePub: string;
|
||||
/**
|
||||
* Transparent identifier of the incoming peer push payment.
|
||||
*/
|
||||
peerPushPaymentIncomingId: string;
|
||||
}
|
||||
|
||||
export const codecForAcceptPeerPushPaymentRequest =
|
||||
(): Codec<AcceptPeerPushPaymentRequest> =>
|
||||
buildCodecForObject<AcceptPeerPushPaymentRequest>()
|
||||
.property("pursePub", codecForString())
|
||||
.property("exchangeBaseUrl", codecForString())
|
||||
.property("peerPushPaymentIncomingId", codecForString())
|
||||
.build("AcceptPeerPushPaymentRequest");
|
||||
|
@ -70,7 +70,7 @@ import {
|
||||
TipCreateConfirmation,
|
||||
TipCreateRequest,
|
||||
TippingReserveStatus,
|
||||
} from "./merchantApiTypes";
|
||||
} from "./merchantApiTypes.js";
|
||||
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
@ -478,14 +478,14 @@ class BankServiceBase {
|
||||
protected globalTestState: GlobalTestState,
|
||||
protected bankConfig: BankConfig,
|
||||
protected configFile: string,
|
||||
) { }
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Work in progress. The key point is that both Sandbox and Nexus
|
||||
* will be configured and started by this class.
|
||||
*/
|
||||
class EufinBankService extends BankServiceBase implements BankServiceHandle {
|
||||
class LibEuFinBankService extends BankServiceBase implements BankServiceHandle {
|
||||
sandboxProc: ProcessWrapper | undefined;
|
||||
nexusProc: ProcessWrapper | undefined;
|
||||
|
||||
@ -494,8 +494,8 @@ class EufinBankService extends BankServiceBase implements BankServiceHandle {
|
||||
static async create(
|
||||
gc: GlobalTestState,
|
||||
bc: BankConfig,
|
||||
): Promise<EufinBankService> {
|
||||
return new EufinBankService(gc, bc, "foo");
|
||||
): Promise<LibEuFinBankService> {
|
||||
return new LibEuFinBankService(gc, bc, "foo");
|
||||
}
|
||||
|
||||
get port() {
|
||||
@ -761,7 +761,10 @@ class EufinBankService extends BankServiceBase implements BankServiceHandle {
|
||||
}
|
||||
}
|
||||
|
||||
class PybankService extends BankServiceBase implements BankServiceHandle {
|
||||
/**
|
||||
* Implementation of the bank service using the "taler-fakebank-run" tool.
|
||||
*/
|
||||
class FakebankService extends BankServiceBase implements BankServiceHandle {
|
||||
proc: ProcessWrapper | undefined;
|
||||
|
||||
http = new NodeHttpLib();
|
||||
@ -769,41 +772,23 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
|
||||
static async create(
|
||||
gc: GlobalTestState,
|
||||
bc: BankConfig,
|
||||
): Promise<PybankService> {
|
||||
): Promise<FakebankService> {
|
||||
const config = new Configuration();
|
||||
setTalerPaths(config, gc.testDir + "/talerhome");
|
||||
config.setString("taler", "currency", bc.currency);
|
||||
config.setString("bank", "database", bc.database);
|
||||
config.setString("bank", "http_port", `${bc.httpPort}`);
|
||||
config.setString("bank", "serve", "http");
|
||||
config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
|
||||
config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
|
||||
config.setString(
|
||||
"bank",
|
||||
"allow_registrations",
|
||||
bc.allowRegistrations ? "yes" : "no",
|
||||
);
|
||||
const cfgFilename = gc.testDir + "/bank.conf";
|
||||
config.write(cfgFilename);
|
||||
|
||||
await sh(
|
||||
gc,
|
||||
"taler-bank-manage_django",
|
||||
`taler-bank-manage -c '${cfgFilename}' django migrate`,
|
||||
);
|
||||
await sh(
|
||||
gc,
|
||||
"taler-bank-manage_django",
|
||||
`taler-bank-manage -c '${cfgFilename}' django provide_accounts`,
|
||||
);
|
||||
|
||||
return new PybankService(gc, bc, cfgFilename);
|
||||
return new FakebankService(gc, bc, cfgFilename);
|
||||
}
|
||||
|
||||
setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
|
||||
const config = Configuration.load(this.configFile);
|
||||
config.setString("bank", "suggested_exchange", e.baseUrl);
|
||||
config.setString("bank", "suggested_exchange_payto", exchangePayto);
|
||||
config.write(this.configFile);
|
||||
}
|
||||
|
||||
@ -815,21 +800,6 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
|
||||
accountName: string,
|
||||
password: string,
|
||||
): Promise<HarnessExchangeBankAccount> {
|
||||
await sh(
|
||||
this.globalTestState,
|
||||
"taler-bank-manage_django",
|
||||
`taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`,
|
||||
);
|
||||
await sh(
|
||||
this.globalTestState,
|
||||
"taler-bank-manage_django",
|
||||
`taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`,
|
||||
);
|
||||
await sh(
|
||||
this.globalTestState,
|
||||
"taler-bank-manage_django",
|
||||
`taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`,
|
||||
);
|
||||
return {
|
||||
accountName: accountName,
|
||||
accountPassword: password,
|
||||
@ -844,8 +814,8 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.proc = this.globalTestState.spawnService(
|
||||
"taler-bank-manage",
|
||||
["-c", this.configFile, "serve"],
|
||||
"taler-fakebank-run",
|
||||
["-c", this.configFile],
|
||||
"bank",
|
||||
);
|
||||
}
|
||||
@ -857,7 +827,7 @@ class PybankService extends BankServiceBase implements BankServiceHandle {
|
||||
}
|
||||
|
||||
// Use libeufin bank instead of pybank.
|
||||
const useLibeufinBank = process.env.WALLET_HARNESS_WITH_EUFIN;
|
||||
const useLibeufinBank = true;
|
||||
|
||||
/**
|
||||
* Return a euFin or a pyBank implementation of
|
||||
@ -866,21 +836,21 @@ const useLibeufinBank = process.env.WALLET_HARNESS_WITH_EUFIN;
|
||||
* on a particular env variable.
|
||||
*/
|
||||
function getBankServiceImpl(): {
|
||||
prototype: typeof PybankService.prototype;
|
||||
create: typeof PybankService.create;
|
||||
prototype: typeof FakebankService.prototype;
|
||||
create: typeof FakebankService.create;
|
||||
} {
|
||||
if (useLibeufinBank)
|
||||
return {
|
||||
prototype: EufinBankService.prototype,
|
||||
create: EufinBankService.create,
|
||||
prototype: LibEuFinBankService.prototype,
|
||||
create: LibEuFinBankService.create,
|
||||
};
|
||||
return {
|
||||
prototype: PybankService.prototype,
|
||||
create: PybankService.create,
|
||||
prototype: FakebankService.prototype,
|
||||
create: FakebankService.create,
|
||||
};
|
||||
}
|
||||
|
||||
export type BankService = PybankService;
|
||||
export type BankService = FakebankService;
|
||||
export const BankService = getBankServiceImpl();
|
||||
|
||||
export class FakeBankService {
|
||||
@ -923,7 +893,7 @@ export class FakeBankService {
|
||||
private globalTestState: GlobalTestState,
|
||||
private bankConfig: FakeBankConfig,
|
||||
private configFile: string,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.proc = this.globalTestState.spawnService(
|
||||
@ -1189,7 +1159,7 @@ export class ExchangeService implements ExchangeServiceInterface {
|
||||
private exchangeConfig: ExchangeConfig,
|
||||
private configFilename: string,
|
||||
private keyPair: EddsaKeyPair,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
get name() {
|
||||
return this.exchangeConfig.name;
|
||||
@ -1442,7 +1412,7 @@ export class MerchantApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
public readonly auth: MerchantAuthConfiguration,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
|
||||
const url = new URL("private/auth", this.baseUrl);
|
||||
@ -1635,7 +1605,7 @@ export class MerchantService implements MerchantServiceInterface {
|
||||
private globalState: GlobalTestState,
|
||||
private merchantConfig: MerchantConfig,
|
||||
private configFilename: string,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
private currentTimetravel: Duration | undefined;
|
||||
|
||||
@ -1947,8 +1917,10 @@ export class WalletCli {
|
||||
const resp = await sh(
|
||||
self.globalTestState,
|
||||
`wallet-${self.name}`,
|
||||
`taler-wallet-cli ${self.timetravelArg ?? ""
|
||||
} --no-throttle -LTRACE --wallet-db '${self.dbfile
|
||||
`taler-wallet-cli ${
|
||||
self.timetravelArg ?? ""
|
||||
} --no-throttle -LTRACE --wallet-db '${
|
||||
self.dbfile
|
||||
}' api '${op}' ${shellWrap(JSON.stringify(payload))}`,
|
||||
);
|
||||
console.log("--- wallet core response ---");
|
||||
|
@ -36,7 +36,7 @@ import {
|
||||
runCommand,
|
||||
setupDb,
|
||||
sh,
|
||||
getRandomIban
|
||||
getRandomIban,
|
||||
} from "../harness/harness.js";
|
||||
import {
|
||||
LibeufinSandboxApi,
|
||||
@ -53,13 +53,10 @@ import {
|
||||
CreateAnastasisFacadeRequest,
|
||||
PostNexusTaskRequest,
|
||||
PostNexusPermissionRequest,
|
||||
CreateNexusUserRequest
|
||||
CreateNexusUserRequest,
|
||||
} from "../harness/libeufin-apis.js";
|
||||
|
||||
export {
|
||||
LibeufinSandboxApi,
|
||||
LibeufinNexusApi
|
||||
}
|
||||
export { LibeufinSandboxApi, LibeufinNexusApi };
|
||||
|
||||
export interface LibeufinServices {
|
||||
libeufinSandbox: LibeufinSandboxService;
|
||||
@ -206,6 +203,16 @@ export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await sh(
|
||||
this.globalTestState,
|
||||
"libeufin-sandbox-config",
|
||||
"libeufin-sandbox config default",
|
||||
{
|
||||
...process.env,
|
||||
LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
|
||||
},
|
||||
);
|
||||
|
||||
this.sandboxProc = this.globalTestState.spawnService(
|
||||
"libeufin-sandbox",
|
||||
["serve", "--port", `${this.sandboxConfig.httpPort}`],
|
||||
@ -235,7 +242,8 @@ export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
|
||||
debit: string,
|
||||
credit: string,
|
||||
amount: string, // $currency:x.y
|
||||
subject: string,): Promise<string> {
|
||||
subject: string,
|
||||
): Promise<string> {
|
||||
const stdout = await sh(
|
||||
this.globalTestState,
|
||||
"libeufin-sandbox-maketransfer",
|
||||
@ -428,7 +436,7 @@ export class LibeufinCli {
|
||||
LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl,
|
||||
LIBEUFIN_SANDBOX_USERNAME: "admin",
|
||||
LIBEUFIN_SANDBOX_PASSWORD: "secret",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async checkSandbox(): Promise<void> {
|
||||
@ -436,7 +444,7 @@ export class LibeufinCli {
|
||||
this.globalTestState,
|
||||
"libeufin-cli-checksandbox",
|
||||
"libeufin-cli sandbox check",
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -445,7 +453,7 @@ export class LibeufinCli {
|
||||
this.globalTestState,
|
||||
"libeufin-cli-createebicshost",
|
||||
`libeufin-cli sandbox ebicshost create --host-id=${hostId}`,
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
console.log(stdout);
|
||||
}
|
||||
@ -460,7 +468,7 @@ export class LibeufinCli {
|
||||
` --host-id=${details.hostId}` +
|
||||
` --partner-id=${details.partnerId}` +
|
||||
` --user-id=${details.userId}`,
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
console.log(stdout);
|
||||
}
|
||||
@ -480,7 +488,7 @@ export class LibeufinCli {
|
||||
` --ebics-host-id=${sd.hostId}` +
|
||||
` --ebics-partner-id=${sd.partnerId}` +
|
||||
` --ebics-user-id=${sd.userId}`,
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
console.log(stdout);
|
||||
}
|
||||
@ -490,7 +498,7 @@ export class LibeufinCli {
|
||||
this.globalTestState,
|
||||
"libeufin-cli-generatetransactions",
|
||||
`libeufin-cli sandbox bankaccount generate-transactions ${accountName}`,
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
console.log(stdout);
|
||||
}
|
||||
@ -500,7 +508,7 @@ export class LibeufinCli {
|
||||
this.globalTestState,
|
||||
"libeufin-cli-showsandboxtransactions",
|
||||
`libeufin-cli sandbox bankaccount transactions ${accountName}`,
|
||||
this.env()
|
||||
this.env(),
|
||||
);
|
||||
console.log(stdout);
|
||||
}
|
||||
@ -834,9 +842,12 @@ export async function launchLibeufinServices(
|
||||
libeufinNexus,
|
||||
nb.twgHistoryPermission,
|
||||
);
|
||||
break;
|
||||
break;
|
||||
case "anastasis":
|
||||
await LibeufinNexusApi.createAnastasisFacade(libeufinNexus, nb.anastasisReq);
|
||||
await LibeufinNexusApi.createAnastasisFacade(
|
||||
libeufinNexus,
|
||||
nb.anastasisReq,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
|
||||
debtorName: "mock2",
|
||||
amount: "1",
|
||||
subject: "mock subject",
|
||||
}
|
||||
},
|
||||
);
|
||||
await LibeufinNexusApi.fetchTransactions(nexus, "local-mock");
|
||||
let transactions = await LibeufinNexusApi.getAccountTransactions(
|
||||
@ -106,4 +106,5 @@ export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
|
||||
let el = findNexusPayment("mock subject", transactions.data);
|
||||
t.assertTrue(el instanceof Object);
|
||||
}
|
||||
|
||||
runLibeufinApiBankaccountTest.suites = ["libeufin"];
|
||||
|
@ -17,12 +17,7 @@
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import {
|
||||
AbsoluteTime,
|
||||
ContractTerms,
|
||||
Duration,
|
||||
durationFromSpec,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { AbsoluteTime, ContractTerms, Duration } from "@gnu-taler/taler-util";
|
||||
import {
|
||||
WalletApiOperation,
|
||||
HarnessExchangeBankAccount,
|
||||
@ -42,7 +37,7 @@ import {
|
||||
LibeufinNexusService,
|
||||
LibeufinSandboxApi,
|
||||
LibeufinSandboxService,
|
||||
} from "../harness/libeufin";
|
||||
} from "../harness/libeufin.js";
|
||||
|
||||
const exchangeIban = "DE71500105179674997361";
|
||||
const customerIban = "DE84500105176881385584";
|
||||
|
@ -22,7 +22,6 @@ import { GlobalTestState } from "../harness/harness.js";
|
||||
import {
|
||||
createSimpleTestkudosEnvironment,
|
||||
withdrawViaBank,
|
||||
makeTestPayment,
|
||||
} from "../harness/helpers.js";
|
||||
|
||||
/**
|
||||
@ -55,9 +54,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
|
||||
const checkResp = await wallet.client.call(
|
||||
WalletApiOperation.CheckPeerPushPayment,
|
||||
{
|
||||
contractPriv: resp.contractPriv,
|
||||
exchangeBaseUrl: resp.exchangeBaseUrl,
|
||||
pursePub: resp.pursePub,
|
||||
talerUri: resp.talerUri,
|
||||
},
|
||||
);
|
||||
|
||||
@ -66,8 +63,7 @@ export async function runPeerToPeerTest(t: GlobalTestState) {
|
||||
const acceptResp = await wallet.client.call(
|
||||
WalletApiOperation.AcceptPeerPushPayment,
|
||||
{
|
||||
exchangeBaseUrl: resp.exchangeBaseUrl,
|
||||
pursePub: resp.pursePub,
|
||||
peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -127,36 +127,6 @@ export interface ReserveBankInfo {
|
||||
* Exchange payto URI that the bank will use to fund the reserve.
|
||||
*/
|
||||
exchangePaytoUri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reserve record as stored in the wallet's database.
|
||||
*/
|
||||
export interface ReserveRecord {
|
||||
/**
|
||||
* The reserve public key.
|
||||
*/
|
||||
reservePub: string;
|
||||
|
||||
/**
|
||||
* The reserve private key.
|
||||
*/
|
||||
reservePriv: string;
|
||||
|
||||
/**
|
||||
* The exchange base URL for the reserve.
|
||||
*/
|
||||
exchangeBaseUrl: string;
|
||||
|
||||
/**
|
||||
* Currency of the reserve.
|
||||
*/
|
||||
currency: string;
|
||||
|
||||
/**
|
||||
* Time when the reserve was created.
|
||||
*/
|
||||
timestampCreated: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* Time when the information about this reserve was posted to the bank.
|
||||
@ -165,83 +135,14 @@ export interface ReserveRecord {
|
||||
*
|
||||
* Set to undefined if that hasn't happened yet.
|
||||
*/
|
||||
timestampReserveInfoPosted: TalerProtocolTimestamp | undefined;
|
||||
timestampReserveInfoPosted?: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* Time when the reserve was confirmed by the bank.
|
||||
*
|
||||
* Set to undefined if not confirmed yet.
|
||||
*/
|
||||
timestampBankConfirmed: TalerProtocolTimestamp | undefined;
|
||||
|
||||
/**
|
||||
* Wire information (as payto URI) for the bank account that
|
||||
* transferred funds for this reserve.
|
||||
*/
|
||||
senderWire?: string;
|
||||
|
||||
/**
|
||||
* Amount that was sent by the user to fund the reserve.
|
||||
*/
|
||||
instructedAmount: AmountJson;
|
||||
|
||||
/**
|
||||
* Extra state for when this is a withdrawal involving
|
||||
* a Taler-integrated bank.
|
||||
*/
|
||||
bankInfo?: ReserveBankInfo;
|
||||
|
||||
/**
|
||||
* Restrict withdrawals from this reserve to this age.
|
||||
*/
|
||||
restrictAge?: number;
|
||||
|
||||
/**
|
||||
* Pre-allocated ID of the withdrawal group for the first withdrawal
|
||||
* on this reserve.
|
||||
*/
|
||||
initialWithdrawalGroupId: string;
|
||||
|
||||
/**
|
||||
* Did we start the first withdrawal for this reserve?
|
||||
*
|
||||
* We only report a pending withdrawal for the reserve before
|
||||
* the first withdrawal has started.
|
||||
*/
|
||||
initialWithdrawalStarted: boolean;
|
||||
|
||||
/**
|
||||
* Initial denomination selection, stored here so that
|
||||
* we can show this information in the transactions/balances
|
||||
* before we have a withdrawal group.
|
||||
*/
|
||||
initialDenomSel: DenomSelectionState;
|
||||
|
||||
/**
|
||||
* Current status of the reserve.
|
||||
*/
|
||||
reserveStatus: ReserveRecordStatus;
|
||||
|
||||
/**
|
||||
* Is there any work to be done for this reserve?
|
||||
*
|
||||
* Technically redundant, since the reserveStatus would indicate this.
|
||||
* However, we use the operationStatus for DB indexing of pending operations.
|
||||
*/
|
||||
operationStatus: OperationStatus;
|
||||
|
||||
/**
|
||||
* Retry info, in case the reserve needs to be processed again
|
||||
* later, either due to an error or because the wallet needs to
|
||||
* wait for something.
|
||||
*/
|
||||
retryInfo: RetryInfo | undefined;
|
||||
|
||||
/**
|
||||
* Last error that happened in a reserve operation
|
||||
* (either talking to the bank or the exchange).
|
||||
*/
|
||||
lastError: TalerErrorDetail | undefined;
|
||||
timestampBankConfirmed?: TalerProtocolTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -514,6 +415,11 @@ export interface ExchangeDetailsPointer {
|
||||
updateClock: TalerProtocolTimestamp;
|
||||
}
|
||||
|
||||
export interface MergeReserveInfo {
|
||||
reservePub: string;
|
||||
reservePriv: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange record as stored in the wallet's database.
|
||||
*/
|
||||
@ -568,7 +474,7 @@ export interface ExchangeRecord {
|
||||
* Public key of the reserve that we're currently using for
|
||||
* receiving P2P payments.
|
||||
*/
|
||||
currentMergeReservePub?: string;
|
||||
currentMergeReserveInfo?: MergeReserveInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1373,6 +1279,7 @@ export interface WithdrawalGroupRecord {
|
||||
|
||||
/**
|
||||
* Secret seed used to derive planchets.
|
||||
* Stored since planchets are created lazily.
|
||||
*/
|
||||
secretSeed: string;
|
||||
|
||||
@ -1381,6 +1288,11 @@ export interface WithdrawalGroupRecord {
|
||||
*/
|
||||
reservePub: string;
|
||||
|
||||
/**
|
||||
* The reserve private key.
|
||||
*/
|
||||
reservePriv: string;
|
||||
|
||||
/**
|
||||
* The exchange base URL that we're withdrawing from.
|
||||
* (Redundantly stored, as the reserve record also has this info.)
|
||||
@ -1395,8 +1307,6 @@ export interface WithdrawalGroupRecord {
|
||||
|
||||
/**
|
||||
* When was the withdrawal operation completed?
|
||||
*
|
||||
* FIXME: We should probably drop this and introduce an OperationStatus field.
|
||||
*/
|
||||
timestampFinish?: TalerProtocolTimestamp;
|
||||
|
||||
@ -1406,6 +1316,33 @@ export interface WithdrawalGroupRecord {
|
||||
*/
|
||||
operationStatus: OperationStatus;
|
||||
|
||||
/**
|
||||
* Current status of the reserve.
|
||||
*/
|
||||
reserveStatus: ReserveRecordStatus;
|
||||
|
||||
/**
|
||||
* Amount that was sent by the user to fund the reserve.
|
||||
*/
|
||||
instructedAmount: AmountJson;
|
||||
|
||||
/**
|
||||
* Wire information (as payto URI) for the bank account that
|
||||
* transferred funds for this reserve.
|
||||
*/
|
||||
senderWire?: string;
|
||||
|
||||
/**
|
||||
* Restrict withdrawals from this reserve to this age.
|
||||
*/
|
||||
restrictAge?: number;
|
||||
|
||||
/**
|
||||
* Extra state for when this is a withdrawal involving
|
||||
* a Taler-integrated bank.
|
||||
*/
|
||||
bankInfo?: ReserveBankInfo;
|
||||
|
||||
/**
|
||||
* Amount including fees (i.e. the amount subtracted from the
|
||||
* reserve to withdraw all coins in this withdrawal session).
|
||||
@ -1730,9 +1667,11 @@ export interface PeerPushPaymentInitiationRecord {
|
||||
/**
|
||||
* Record for a push P2P payment that this wallet was offered.
|
||||
*
|
||||
* Primary key: (exchangeBaseUrl, pursePub)
|
||||
* Unique: (exchangeBaseUrl, pursePub)
|
||||
*/
|
||||
export interface PeerPushPaymentIncomingRecord {
|
||||
peerPushPaymentIncomingId: string;
|
||||
|
||||
exchangeBaseUrl: string;
|
||||
|
||||
pursePub: string;
|
||||
@ -1828,16 +1767,6 @@ export const WalletStoresV1 = {
|
||||
}),
|
||||
{},
|
||||
),
|
||||
reserves: describeStore(
|
||||
describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }),
|
||||
{
|
||||
byInitialWithdrawalGroupId: describeIndex(
|
||||
"byInitialWithdrawalGroupId",
|
||||
"initialWithdrawalGroupId",
|
||||
),
|
||||
byStatus: describeIndex("byStatus", "operationStatus"),
|
||||
},
|
||||
),
|
||||
purchases: describeStore(
|
||||
describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
|
||||
{
|
||||
@ -1926,9 +1855,14 @@ export const WalletStoresV1 = {
|
||||
),
|
||||
peerPushPaymentIncoming: describeStore(
|
||||
describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", {
|
||||
keyPath: ["exchangeBaseUrl", "pursePub"],
|
||||
keyPath: "peerPushPaymentIncomingId",
|
||||
}),
|
||||
{},
|
||||
{
|
||||
byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
|
||||
"exchangeBaseUrl",
|
||||
"pursePub",
|
||||
]),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
|
@ -53,7 +53,6 @@ export * from "./operations/exchanges.js";
|
||||
|
||||
export * from "./bank-api-client.js";
|
||||
|
||||
export * from "./operations/reserves.js";
|
||||
export * from "./operations/withdraw.js";
|
||||
export * from "./operations/refresh.js";
|
||||
|
||||
|
@ -73,15 +73,6 @@ export interface MerchantOperations {
|
||||
): Promise<MerchantInfo>;
|
||||
}
|
||||
|
||||
export interface ReserveOperations {
|
||||
processReserve(
|
||||
ws: InternalWalletState,
|
||||
reservePub: string,
|
||||
options?: {
|
||||
forceNow?: boolean;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for exchange-related operations.
|
||||
@ -234,7 +225,6 @@ export interface InternalWalletState {
|
||||
exchangeOps: ExchangeOperations;
|
||||
recoupOps: RecoupOperations;
|
||||
merchantOps: MerchantOperations;
|
||||
reserveOps: ReserveOperations;
|
||||
|
||||
getDenomInfo(
|
||||
ws: InternalWalletState,
|
||||
|
@ -88,7 +88,6 @@ export async function exportBackup(
|
||||
backupProviders: x.backupProviders,
|
||||
tips: x.tips,
|
||||
recoupGroups: x.recoupGroups,
|
||||
reserves: x.reserves,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
@ -128,29 +127,6 @@ export async function exportBackup(
|
||||
});
|
||||
});
|
||||
|
||||
await tx.reserves.iter().forEach((reserve) => {
|
||||
const backupReserve: BackupReserve = {
|
||||
initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
|
||||
(x) => ({
|
||||
count: x.count,
|
||||
denom_pub_hash: x.denomPubHash,
|
||||
}),
|
||||
),
|
||||
initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
|
||||
instructed_amount: Amounts.stringify(reserve.instructedAmount),
|
||||
reserve_priv: reserve.reservePriv,
|
||||
timestamp_created: reserve.timestampCreated,
|
||||
withdrawal_groups:
|
||||
withdrawalGroupsByReserve[reserve.reservePub] ?? [],
|
||||
// FIXME!
|
||||
timestamp_last_activity: reserve.timestampCreated,
|
||||
};
|
||||
const backupReserves = (backupReservesByExchange[
|
||||
reserve.exchangeBaseUrl
|
||||
] ??= []);
|
||||
backupReserves.push(backupReserve);
|
||||
});
|
||||
|
||||
await tx.tips.iter().forEach((tip) => {
|
||||
backupTips.push({
|
||||
exchange_base_url: tip.exchangeBaseUrl,
|
||||
|
@ -236,7 +236,6 @@ export async function importBackup(
|
||||
backupProviders: x.backupProviders,
|
||||
tips: x.tips,
|
||||
recoupGroups: x.recoupGroups,
|
||||
reserves: x.reserves,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
tombstones: x.tombstones,
|
||||
depositGroups: x.depositGroups,
|
||||
@ -427,94 +426,98 @@ export async function importBackup(
|
||||
}
|
||||
}
|
||||
|
||||
for (const backupReserve of backupExchangeDetails.reserves) {
|
||||
const reservePub =
|
||||
cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
|
||||
const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
|
||||
if (tombstoneSet.has(ts)) {
|
||||
continue;
|
||||
}
|
||||
checkLogicInvariant(!!reservePub);
|
||||
const existingReserve = await tx.reserves.get(reservePub);
|
||||
const instructedAmount = Amounts.parseOrThrow(
|
||||
backupReserve.instructed_amount,
|
||||
);
|
||||
if (!existingReserve) {
|
||||
let bankInfo: ReserveBankInfo | undefined;
|
||||
if (backupReserve.bank_info) {
|
||||
bankInfo = {
|
||||
exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
|
||||
statusUrl: backupReserve.bank_info.status_url,
|
||||
confirmUrl: backupReserve.bank_info.confirm_url,
|
||||
};
|
||||
}
|
||||
await tx.reserves.put({
|
||||
currency: instructedAmount.currency,
|
||||
instructedAmount,
|
||||
exchangeBaseUrl: backupExchangeDetails.base_url,
|
||||
reservePub,
|
||||
reservePriv: backupReserve.reserve_priv,
|
||||
bankInfo,
|
||||
timestampCreated: backupReserve.timestamp_created,
|
||||
timestampBankConfirmed:
|
||||
backupReserve.bank_info?.timestamp_bank_confirmed,
|
||||
timestampReserveInfoPosted:
|
||||
backupReserve.bank_info?.timestamp_reserve_info_posted,
|
||||
senderWire: backupReserve.sender_wire,
|
||||
retryInfo: RetryInfo.reset(),
|
||||
lastError: undefined,
|
||||
initialWithdrawalGroupId:
|
||||
backupReserve.initial_withdrawal_group_id,
|
||||
initialWithdrawalStarted:
|
||||
backupReserve.withdrawal_groups.length > 0,
|
||||
// FIXME!
|
||||
reserveStatus: ReserveRecordStatus.QueryingStatus,
|
||||
initialDenomSel: await getDenomSelStateFromBackup(
|
||||
tx,
|
||||
backupExchangeDetails.base_url,
|
||||
backupReserve.initial_selected_denoms,
|
||||
),
|
||||
// FIXME!
|
||||
operationStatus: OperationStatus.Pending,
|
||||
});
|
||||
}
|
||||
for (const backupWg of backupReserve.withdrawal_groups) {
|
||||
const ts = makeEventId(
|
||||
TombstoneTag.DeleteWithdrawalGroup,
|
||||
backupWg.withdrawal_group_id,
|
||||
);
|
||||
if (tombstoneSet.has(ts)) {
|
||||
continue;
|
||||
}
|
||||
const existingWg = await tx.withdrawalGroups.get(
|
||||
backupWg.withdrawal_group_id,
|
||||
);
|
||||
if (!existingWg) {
|
||||
await tx.withdrawalGroups.put({
|
||||
denomsSel: await getDenomSelStateFromBackup(
|
||||
tx,
|
||||
backupExchangeDetails.base_url,
|
||||
backupWg.selected_denoms,
|
||||
),
|
||||
exchangeBaseUrl: backupExchangeDetails.base_url,
|
||||
lastError: undefined,
|
||||
rawWithdrawalAmount: Amounts.parseOrThrow(
|
||||
backupWg.raw_withdrawal_amount,
|
||||
),
|
||||
reservePub,
|
||||
retryInfo: RetryInfo.reset(),
|
||||
secretSeed: backupWg.secret_seed,
|
||||
timestampStart: backupWg.timestamp_created,
|
||||
timestampFinish: backupWg.timestamp_finish,
|
||||
withdrawalGroupId: backupWg.withdrawal_group_id,
|
||||
denomSelUid: backupWg.selected_denoms_id,
|
||||
operationStatus: backupWg.timestamp_finish
|
||||
? OperationStatus.Finished
|
||||
: OperationStatus.Pending,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: import reserves with new schema
|
||||
|
||||
// for (const backupReserve of backupExchangeDetails.reserves) {
|
||||
// const reservePub =
|
||||
// cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
|
||||
// const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
|
||||
// if (tombstoneSet.has(ts)) {
|
||||
// continue;
|
||||
// }
|
||||
// checkLogicInvariant(!!reservePub);
|
||||
// const existingReserve = await tx.reserves.get(reservePub);
|
||||
// const instructedAmount = Amounts.parseOrThrow(
|
||||
// backupReserve.instructed_amount,
|
||||
// );
|
||||
// if (!existingReserve) {
|
||||
// let bankInfo: ReserveBankInfo | undefined;
|
||||
// if (backupReserve.bank_info) {
|
||||
// bankInfo = {
|
||||
// exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
|
||||
// statusUrl: backupReserve.bank_info.status_url,
|
||||
// confirmUrl: backupReserve.bank_info.confirm_url,
|
||||
// };
|
||||
// }
|
||||
// await tx.reserves.put({
|
||||
// currency: instructedAmount.currency,
|
||||
// instructedAmount,
|
||||
// exchangeBaseUrl: backupExchangeDetails.base_url,
|
||||
// reservePub,
|
||||
// reservePriv: backupReserve.reserve_priv,
|
||||
// bankInfo,
|
||||
// timestampCreated: backupReserve.timestamp_created,
|
||||
// timestampBankConfirmed:
|
||||
// backupReserve.bank_info?.timestamp_bank_confirmed,
|
||||
// timestampReserveInfoPosted:
|
||||
// backupReserve.bank_info?.timestamp_reserve_info_posted,
|
||||
// senderWire: backupReserve.sender_wire,
|
||||
// retryInfo: RetryInfo.reset(),
|
||||
// lastError: undefined,
|
||||
// initialWithdrawalGroupId:
|
||||
// backupReserve.initial_withdrawal_group_id,
|
||||
// initialWithdrawalStarted:
|
||||
// backupReserve.withdrawal_groups.length > 0,
|
||||
// // FIXME!
|
||||
// reserveStatus: ReserveRecordStatus.QueryingStatus,
|
||||
// initialDenomSel: await getDenomSelStateFromBackup(
|
||||
// tx,
|
||||
// backupExchangeDetails.base_url,
|
||||
// backupReserve.initial_selected_denoms,
|
||||
// ),
|
||||
// // FIXME!
|
||||
// operationStatus: OperationStatus.Pending,
|
||||
// });
|
||||
// }
|
||||
// for (const backupWg of backupReserve.withdrawal_groups) {
|
||||
// const ts = makeEventId(
|
||||
// TombstoneTag.DeleteWithdrawalGroup,
|
||||
// backupWg.withdrawal_group_id,
|
||||
// );
|
||||
// if (tombstoneSet.has(ts)) {
|
||||
// continue;
|
||||
// }
|
||||
// const existingWg = await tx.withdrawalGroups.get(
|
||||
// backupWg.withdrawal_group_id,
|
||||
// );
|
||||
// if (!existingWg) {
|
||||
// await tx.withdrawalGroups.put({
|
||||
// denomsSel: await getDenomSelStateFromBackup(
|
||||
// tx,
|
||||
// backupExchangeDetails.base_url,
|
||||
// backupWg.selected_denoms,
|
||||
// ),
|
||||
// exchangeBaseUrl: backupExchangeDetails.base_url,
|
||||
// lastError: undefined,
|
||||
// rawWithdrawalAmount: Amounts.parseOrThrow(
|
||||
// backupWg.raw_withdrawal_amount,
|
||||
// ),
|
||||
// reservePub,
|
||||
// retryInfo: RetryInfo.reset(),
|
||||
// secretSeed: backupWg.secret_seed,
|
||||
// timestampStart: backupWg.timestamp_created,
|
||||
// timestampFinish: backupWg.timestamp_finish,
|
||||
// withdrawalGroupId: backupWg.withdrawal_group_id,
|
||||
// denomSelUid: backupWg.selected_denoms_id,
|
||||
// operationStatus: backupWg.timestamp_finish
|
||||
// ? OperationStatus.Finished
|
||||
// : OperationStatus.Pending,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
for (const backupProposal of backupBlob.proposals) {
|
||||
@ -920,10 +923,6 @@ export async function importBackup(
|
||||
} else if (type === TombstoneTag.DeleteRefund) {
|
||||
// Nothing required, will just prevent display
|
||||
// in the transactions list
|
||||
} else if (type === TombstoneTag.DeleteReserve) {
|
||||
// FIXME: Once we also have account (=kyc) reserves,
|
||||
// we need to check if the reserve is an account before deleting here
|
||||
await tx.reserves.delete(rest[0]);
|
||||
} else if (type === TombstoneTag.DeleteTip) {
|
||||
await tx.tips.delete(rest[0]);
|
||||
} else if (type === TombstoneTag.DeleteWithdrawalGroup) {
|
||||
|
@ -41,7 +41,6 @@ interface WalletBalance {
|
||||
export async function getBalancesInsideTransaction(
|
||||
ws: InternalWalletState,
|
||||
tx: GetReadOnlyAccess<{
|
||||
reserves: typeof WalletStoresV1.reserves;
|
||||
coins: typeof WalletStoresV1.coins;
|
||||
refreshGroups: typeof WalletStoresV1.refreshGroups;
|
||||
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
|
||||
@ -65,17 +64,6 @@ export async function getBalancesInsideTransaction(
|
||||
return balanceStore[currency];
|
||||
};
|
||||
|
||||
// Initialize balance to zero, even if we didn't start withdrawing yet.
|
||||
await tx.reserves.iter().forEach((r) => {
|
||||
const b = initBalance(r.currency);
|
||||
if (!r.initialWithdrawalStarted) {
|
||||
b.pendingIncoming = Amounts.add(
|
||||
b.pendingIncoming,
|
||||
r.initialDenomSel.totalCoinValue,
|
||||
).amount;
|
||||
}
|
||||
});
|
||||
|
||||
await tx.coins.iter().forEach((c) => {
|
||||
// Only count fresh coins, as dormant coins will
|
||||
// already be in a refresh session.
|
||||
@ -154,7 +142,6 @@ export async function getBalances(
|
||||
.mktx((x) => ({
|
||||
coins: x.coins,
|
||||
refreshGroups: x.refreshGroups,
|
||||
reserves: x.reserves,
|
||||
purchases: x.purchases,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
This file is part of GNU Taler
|
||||
(C) 2019 GNUnet e.V.
|
||||
(C) 2022 GNUnet e.V.
|
||||
|
||||
GNU Taler is free software; you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
@ -30,35 +30,35 @@ import {
|
||||
codecForAmountString,
|
||||
codecForAny,
|
||||
codecForExchangeGetContractResponse,
|
||||
constructPayPushUri,
|
||||
ContractTermsUtil,
|
||||
decodeCrock,
|
||||
Duration,
|
||||
eddsaGetPublic,
|
||||
encodeCrock,
|
||||
ExchangePurseMergeRequest,
|
||||
getRandomBytes,
|
||||
InitiatePeerPushPaymentRequest,
|
||||
InitiatePeerPushPaymentResponse,
|
||||
j2s,
|
||||
Logger,
|
||||
parsePayPushUri,
|
||||
strcmp,
|
||||
TalerProtocolTimestamp,
|
||||
UnblindedSignature,
|
||||
WalletAccountMergeFlags,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { url } from "inspector";
|
||||
import {
|
||||
CoinStatus,
|
||||
MergeReserveInfo,
|
||||
OperationStatus,
|
||||
ReserveRecord,
|
||||
ReserveRecordStatus,
|
||||
WithdrawalGroupRecord,
|
||||
} from "../db.js";
|
||||
import {
|
||||
checkSuccessResponseOrThrow,
|
||||
readSuccessResponseJsonOrThrow,
|
||||
throwUnexpectedRequestError,
|
||||
} from "../util/http.js";
|
||||
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { checkDbInvariant } from "../util/invariants.js";
|
||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||
|
||||
const logger = new Logger("operations/peer-to-peer.ts");
|
||||
|
||||
@ -265,6 +265,10 @@ export async function initiatePeerToPeerPush(
|
||||
mergePriv: mergePair.priv,
|
||||
pursePub: pursePair.pub,
|
||||
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
|
||||
talerUri: constructPayPushUri({
|
||||
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
|
||||
contractPriv: econtractResp.contractPriv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -281,26 +285,19 @@ export async function checkPeerPushPayment(
|
||||
ws: InternalWalletState,
|
||||
req: CheckPeerPushPaymentRequest,
|
||||
): Promise<CheckPeerPushPaymentResponse> {
|
||||
const getPurseUrl = new URL(
|
||||
`purses/${req.pursePub}/deposit`,
|
||||
req.exchangeBaseUrl,
|
||||
);
|
||||
// FIXME: Check if existing record exists!
|
||||
|
||||
const contractPub = encodeCrock(
|
||||
eddsaGetPublic(decodeCrock(req.contractPriv)),
|
||||
);
|
||||
const uri = parsePayPushUri(req.talerUri);
|
||||
|
||||
const purseHttpResp = await ws.http.get(getPurseUrl.href);
|
||||
if (!uri) {
|
||||
throw Error("got invalid taler://pay-push URI");
|
||||
}
|
||||
|
||||
const purseStatus = await readSuccessResponseJsonOrThrow(
|
||||
purseHttpResp,
|
||||
codecForExchangePurseStatus(),
|
||||
);
|
||||
const exchangeBaseUrl = uri.exchangeBaseUrl;
|
||||
const contractPriv = uri.contractPriv;
|
||||
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
|
||||
|
||||
const getContractUrl = new URL(
|
||||
`contracts/${contractPub}`,
|
||||
req.exchangeBaseUrl,
|
||||
);
|
||||
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
|
||||
|
||||
const contractHttpResp = await ws.http.get(getContractUrl.href);
|
||||
|
||||
@ -309,22 +306,36 @@ export async function checkPeerPushPayment(
|
||||
codecForExchangeGetContractResponse(),
|
||||
);
|
||||
|
||||
const pursePub = contractResp.purse_pub;
|
||||
|
||||
const dec = await ws.cryptoApi.decryptContractForMerge({
|
||||
ciphertext: contractResp.econtract,
|
||||
contractPriv: req.contractPriv,
|
||||
pursePub: req.pursePub,
|
||||
contractPriv: contractPriv,
|
||||
pursePub: pursePub,
|
||||
});
|
||||
|
||||
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
|
||||
|
||||
const purseHttpResp = await ws.http.get(getPurseUrl.href);
|
||||
|
||||
const purseStatus = await readSuccessResponseJsonOrThrow(
|
||||
purseHttpResp,
|
||||
codecForExchangePurseStatus(),
|
||||
);
|
||||
|
||||
const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
|
||||
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
peerPushPaymentIncoming: x.peerPushPaymentIncoming,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
await tx.peerPushPaymentIncoming.add({
|
||||
contractPriv: req.contractPriv,
|
||||
exchangeBaseUrl: req.exchangeBaseUrl,
|
||||
peerPushPaymentIncomingId,
|
||||
contractPriv: contractPriv,
|
||||
exchangeBaseUrl: exchangeBaseUrl,
|
||||
mergePriv: dec.mergePriv,
|
||||
pursePub: req.pursePub,
|
||||
pursePub: pursePub,
|
||||
timestampAccepted: TalerProtocolTimestamp.now(),
|
||||
contractTerms: dec.contractTerms,
|
||||
});
|
||||
@ -333,6 +344,7 @@ export async function checkPeerPushPayment(
|
||||
return {
|
||||
amount: purseStatus.balance,
|
||||
contractTerms: dec.contractTerms,
|
||||
peerPushPaymentIncomingId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -343,9 +355,9 @@ export function talerPaytoFromExchangeReserve(
|
||||
const url = new URL(exchangeBaseUrl);
|
||||
let proto: string;
|
||||
if (url.protocol === "http:") {
|
||||
proto = "taler+http";
|
||||
proto = "taler-reserve-http";
|
||||
} else if (url.protocol === "https:") {
|
||||
proto = "taler";
|
||||
proto = "taler-reserve";
|
||||
} else {
|
||||
throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
|
||||
}
|
||||
@ -365,69 +377,45 @@ export async function acceptPeerPushPayment(
|
||||
const peerInc = await ws.db
|
||||
.mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
|
||||
.runReadOnly(async (tx) => {
|
||||
return tx.peerPushPaymentIncoming.get([
|
||||
req.exchangeBaseUrl,
|
||||
req.pursePub,
|
||||
]);
|
||||
return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId);
|
||||
});
|
||||
|
||||
if (!peerInc) {
|
||||
throw Error("can't accept unknown incoming p2p push payment");
|
||||
throw Error(
|
||||
`can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`,
|
||||
);
|
||||
}
|
||||
|
||||
const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
|
||||
|
||||
// We have to create the key pair outside of the transaction,
|
||||
// We have to eagerly create the key pair outside of the transaction,
|
||||
// due to the async crypto API.
|
||||
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
|
||||
|
||||
const reserve: ReserveRecord | undefined = await ws.db
|
||||
const mergeReserveInfo: MergeReserveInfo = await ws.db
|
||||
.mktx((x) => ({
|
||||
exchanges: x.exchanges,
|
||||
reserves: x.reserves,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
|
||||
const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl);
|
||||
checkDbInvariant(!!ex);
|
||||
if (ex.currentMergeReservePub) {
|
||||
return await tx.reserves.get(ex.currentMergeReservePub);
|
||||
if (ex.currentMergeReserveInfo) {
|
||||
return ex.currentMergeReserveInfo;
|
||||
}
|
||||
const rec: ReserveRecord = {
|
||||
exchangeBaseUrl: req.exchangeBaseUrl,
|
||||
// FIXME: field will be removed in the future, folded into withdrawal/p2p record.
|
||||
reserveStatus: ReserveRecordStatus.Dormant,
|
||||
timestampCreated: TalerProtocolTimestamp.now(),
|
||||
instructedAmount: Amounts.getZero(amount.currency),
|
||||
currency: amount.currency,
|
||||
reservePub: newReservePair.pub,
|
||||
await tx.exchanges.put(ex);
|
||||
ex.currentMergeReserveInfo = {
|
||||
reservePriv: newReservePair.priv,
|
||||
timestampBankConfirmed: undefined,
|
||||
timestampReserveInfoPosted: undefined,
|
||||
// FIXME!
|
||||
initialDenomSel: undefined as any,
|
||||
// FIXME!
|
||||
initialWithdrawalGroupId: "",
|
||||
initialWithdrawalStarted: false,
|
||||
lastError: undefined,
|
||||
operationStatus: OperationStatus.Pending,
|
||||
retryInfo: undefined,
|
||||
bankInfo: undefined,
|
||||
restrictAge: undefined,
|
||||
senderWire: undefined,
|
||||
reservePub: newReservePair.pub,
|
||||
};
|
||||
await tx.reserves.put(rec);
|
||||
return rec;
|
||||
return ex.currentMergeReserveInfo;
|
||||
});
|
||||
|
||||
if (!reserve) {
|
||||
throw Error("can't create reserve");
|
||||
}
|
||||
|
||||
const mergeTimestamp = TalerProtocolTimestamp.now();
|
||||
|
||||
const reservePayto = talerPaytoFromExchangeReserve(
|
||||
reserve.exchangeBaseUrl,
|
||||
reserve.reservePub,
|
||||
peerInc.exchangeBaseUrl,
|
||||
mergeReserveInfo.reservePub,
|
||||
);
|
||||
|
||||
const sigRes = await ws.cryptoApi.signPurseMerge({
|
||||
@ -442,12 +430,12 @@ export async function acceptPeerPushPayment(
|
||||
purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
|
||||
pursePub: peerInc.pursePub,
|
||||
reservePayto,
|
||||
reservePriv: reserve.reservePriv,
|
||||
reservePriv: mergeReserveInfo.reservePriv,
|
||||
});
|
||||
|
||||
const mergePurseUrl = new URL(
|
||||
`purses/${req.pursePub}/merge`,
|
||||
req.exchangeBaseUrl,
|
||||
`purses/${peerInc.pursePub}/merge`,
|
||||
peerInc.exchangeBaseUrl,
|
||||
);
|
||||
|
||||
const mergeReq: ExchangePurseMergeRequest = {
|
||||
@ -459,6 +447,17 @@ export async function acceptPeerPushPayment(
|
||||
|
||||
const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
|
||||
|
||||
logger.info(`merge request: ${j2s(mergeReq)}`);
|
||||
const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny());
|
||||
logger.info(`merge result: ${j2s(res)}`);
|
||||
logger.info(`merge response: ${j2s(res)}`);
|
||||
|
||||
await internalCreateWithdrawalGroup(ws, {
|
||||
amount,
|
||||
exchangeBaseUrl: peerInc.exchangeBaseUrl,
|
||||
reserveStatus: ReserveRecordStatus.QueryingStatus,
|
||||
reserveKeyPair: {
|
||||
priv: mergeReserveInfo.reservePriv,
|
||||
pub: mergeReserveInfo.reservePub,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -70,44 +70,6 @@ async function gatherExchangePending(
|
||||
});
|
||||
}
|
||||
|
||||
async function gatherReservePending(
|
||||
tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>,
|
||||
now: AbsoluteTime,
|
||||
resp: PendingOperationsResponse,
|
||||
): Promise<void> {
|
||||
const reserves = await tx.reserves.indexes.byStatus.getAll(
|
||||
OperationStatus.Pending,
|
||||
);
|
||||
for (const reserve of reserves) {
|
||||
const reserveType = reserve.bankInfo
|
||||
? ReserveType.TalerBankWithdraw
|
||||
: ReserveType.Manual;
|
||||
switch (reserve.reserveStatus) {
|
||||
case ReserveRecordStatus.Dormant:
|
||||
// nothing to report as pending
|
||||
break;
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
case ReserveRecordStatus.QueryingStatus:
|
||||
case ReserveRecordStatus.RegisteringBank: {
|
||||
resp.pendingOperations.push({
|
||||
type: PendingTaskType.Reserve,
|
||||
givesLifeness: true,
|
||||
timestampDue: reserve.retryInfo?.nextRetry ?? AbsoluteTime.now(),
|
||||
stage: reserve.reserveStatus,
|
||||
timestampCreated: reserve.timestampCreated,
|
||||
reserveType,
|
||||
reservePub: reserve.reservePub,
|
||||
retryInfo: reserve.retryInfo,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// FIXME: report problem!
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function gatherRefreshPending(
|
||||
tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>,
|
||||
now: AbsoluteTime,
|
||||
@ -336,7 +298,6 @@ export async function getPendingOperations(
|
||||
backupProviders: x.backupProviders,
|
||||
exchanges: x.exchanges,
|
||||
exchangeDetails: x.exchangeDetails,
|
||||
reserves: x.reserves,
|
||||
refreshGroups: x.refreshGroups,
|
||||
coins: x.coins,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
@ -352,7 +313,6 @@ export async function getPendingOperations(
|
||||
pendingOperations: [],
|
||||
};
|
||||
await gatherExchangePending(tx, now, resp);
|
||||
await gatherReservePending(tx, now, resp);
|
||||
await gatherRefreshPending(tx, now, resp);
|
||||
await gatherWithdrawalPending(tx, now, resp);
|
||||
await gatherProposalPending(tx, now, resp);
|
||||
|
@ -26,28 +26,35 @@
|
||||
*/
|
||||
import {
|
||||
Amounts,
|
||||
codecForRecoupConfirmation, encodeCrock, getRandomBytes, j2s, Logger, NotificationType,
|
||||
codecForRecoupConfirmation,
|
||||
encodeCrock,
|
||||
getRandomBytes,
|
||||
j2s,
|
||||
Logger,
|
||||
NotificationType,
|
||||
RefreshReason,
|
||||
TalerErrorDetail,
|
||||
TalerProtocolTimestamp, URL
|
||||
TalerProtocolTimestamp,
|
||||
URL,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import {
|
||||
CoinRecord,
|
||||
CoinSourceType,
|
||||
CoinStatus, OperationStatus, RecoupGroupRecord,
|
||||
CoinStatus,
|
||||
OperationStatus,
|
||||
RecoupGroupRecord,
|
||||
RefreshCoinSource,
|
||||
ReserveRecordStatus, WalletStoresV1, WithdrawCoinSource
|
||||
ReserveRecordStatus,
|
||||
WalletStoresV1,
|
||||
WithdrawCoinSource,
|
||||
} from "../db.js";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||
import { GetReadWriteAccess } from "../util/query.js";
|
||||
import {
|
||||
RetryInfo
|
||||
} from "../util/retries.js";
|
||||
import { RetryInfo } from "../util/retries.js";
|
||||
import { guardOperationException } from "./common.js";
|
||||
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
|
||||
import { getReserveRequestTimeout, processReserve } from "./reserves.js";
|
||||
|
||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||
|
||||
const logger = new Logger("operations/recoup.ts");
|
||||
|
||||
@ -182,34 +189,24 @@ async function recoupWithdrawCoin(
|
||||
cs: WithdrawCoinSource,
|
||||
): Promise<void> {
|
||||
const reservePub = cs.reservePub;
|
||||
const d = await ws.db
|
||||
const denomInfo = await ws.db
|
||||
.mktx((x) => ({
|
||||
reserves: x.reserves,
|
||||
denominations: x.denominations,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
const reserve = await tx.reserves.get(reservePub);
|
||||
if (!reserve) {
|
||||
return;
|
||||
}
|
||||
const denomInfo = await ws.getDenomInfo(
|
||||
ws,
|
||||
tx,
|
||||
reserve.exchangeBaseUrl,
|
||||
coin.exchangeBaseUrl,
|
||||
coin.denomPubHash,
|
||||
);
|
||||
if (!denomInfo) {
|
||||
return;
|
||||
}
|
||||
return { reserve, denomInfo };
|
||||
return denomInfo;
|
||||
});
|
||||
if (!d) {
|
||||
if (!denomInfo) {
|
||||
// FIXME: We should at least emit some pending operation / warning for this?
|
||||
return;
|
||||
}
|
||||
|
||||
const { reserve, denomInfo } = d;
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RecoupStarted,
|
||||
});
|
||||
@ -224,9 +221,7 @@ async function recoupWithdrawCoin(
|
||||
});
|
||||
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
|
||||
logger.trace(`requesting recoup via ${reqUrl.href}`);
|
||||
const resp = await ws.http.postJson(reqUrl.href, recoupRequest, {
|
||||
timeout: getReserveRequestTimeout(reserve),
|
||||
});
|
||||
const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
|
||||
const recoupConfirmation = await readSuccessResponseJsonOrThrow(
|
||||
resp,
|
||||
codecForRecoupConfirmation(),
|
||||
@ -244,7 +239,6 @@ async function recoupWithdrawCoin(
|
||||
.mktx((x) => ({
|
||||
coins: x.coins,
|
||||
denominations: x.denominations,
|
||||
reserves: x.reserves,
|
||||
recoupGroups: x.recoupGroups,
|
||||
refreshGroups: x.refreshGroups,
|
||||
}))
|
||||
@ -260,18 +254,12 @@ async function recoupWithdrawCoin(
|
||||
if (!updatedCoin) {
|
||||
return;
|
||||
}
|
||||
const updatedReserve = await tx.reserves.get(reserve.reservePub);
|
||||
if (!updatedReserve) {
|
||||
return;
|
||||
}
|
||||
updatedCoin.status = CoinStatus.Dormant;
|
||||
const currency = updatedCoin.currentAmount.currency;
|
||||
updatedCoin.currentAmount = Amounts.getZero(currency);
|
||||
updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
|
||||
updatedReserve.retryInfo = RetryInfo.reset();
|
||||
updatedReserve.operationStatus = OperationStatus.Pending;
|
||||
await tx.coins.put(updatedCoin);
|
||||
await tx.reserves.put(updatedReserve);
|
||||
// FIXME: Actually withdraw here!
|
||||
// await internalCreateWithdrawalGroup(ws, {...});
|
||||
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
|
||||
});
|
||||
|
||||
@ -341,7 +329,6 @@ async function recoupRefreshCoin(
|
||||
.mktx((x) => ({
|
||||
coins: x.coins,
|
||||
denominations: x.denominations,
|
||||
reserves: x.reserves,
|
||||
recoupGroups: x.recoupGroups,
|
||||
refreshGroups: x.refreshGroups,
|
||||
}))
|
||||
@ -446,12 +433,6 @@ async function processRecoupGroupImpl(
|
||||
reserveSet.add(coin.coinSource.reservePub);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of reserveSet.values()) {
|
||||
processReserve(ws, r, { forceNow: true }).catch((e) => {
|
||||
logger.error(`processing reserve ${r} after recoup failed`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRecoupGroup(
|
||||
|
@ -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}`,
|
||||
}),
|
||||
);
|
||||
}
|
@ -39,12 +39,12 @@ import {
|
||||
URL,
|
||||
PreparePayResultType,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { createTalerWithdrawReserve } from "./reserves.js";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { confirmPay, preparePayForUri } from "./pay.js";
|
||||
import { getBalances } from "./balance.js";
|
||||
import { applyRefund } from "./refund.js";
|
||||
import { checkLogicInvariant } from "../util/invariants.js";
|
||||
import { acceptWithdrawalFromUri } from "./withdraw.js";
|
||||
|
||||
const logger = new Logger("operations/testing.ts");
|
||||
|
||||
@ -104,14 +104,11 @@ export async function withdrawTestBalance(
|
||||
amount,
|
||||
);
|
||||
|
||||
await createTalerWithdrawReserve(
|
||||
ws,
|
||||
wresp.taler_withdraw_uri,
|
||||
exchangeBaseUrl,
|
||||
{
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
},
|
||||
);
|
||||
await acceptWithdrawalFromUri(ws, {
|
||||
talerWithdrawUri: wresp.taler_withdraw_uri,
|
||||
selectedExchange: exchangeBaseUrl,
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
});
|
||||
|
||||
await confirmBankWithdrawalUri(
|
||||
ws.http,
|
||||
|
@ -36,7 +36,6 @@ import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import {
|
||||
AbortStatus,
|
||||
RefundState,
|
||||
ReserveRecord,
|
||||
ReserveRecordStatus,
|
||||
WalletRefundItem,
|
||||
} from "../db.js";
|
||||
@ -44,9 +43,8 @@ import { processDepositGroup } from "./deposits.js";
|
||||
import { getExchangeDetails } from "./exchanges.js";
|
||||
import { processPurchasePay } from "./pay.js";
|
||||
import { processRefreshGroup } from "./refresh.js";
|
||||
import { getFundingPaytoUris } from "./reserves.js";
|
||||
import { processTip } from "./tip.js";
|
||||
import { processWithdrawGroup } from "./withdraw.js";
|
||||
import { processWithdrawalGroup } from "./withdraw.js";
|
||||
|
||||
const logger = new Logger("taler-wallet-core:transactions.ts");
|
||||
|
||||
@ -127,7 +125,6 @@ export async function getTransactions(
|
||||
proposals: x.proposals,
|
||||
purchases: x.purchases,
|
||||
refreshGroups: x.refreshGroups,
|
||||
reserves: x.reserves,
|
||||
tips: x.tips,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
planchets: x.planchets,
|
||||
@ -151,24 +148,13 @@ export async function getTransactions(
|
||||
if (shouldSkipSearch(transactionsRequest, [])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await tx.reserves.get(wsr.reservePub);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
let amountRaw: AmountJson | undefined = undefined;
|
||||
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
|
||||
amountRaw = r.instructedAmount;
|
||||
} else {
|
||||
amountRaw = wsr.denomsSel.totalWithdrawCost;
|
||||
}
|
||||
let withdrawalDetails: WithdrawalDetails;
|
||||
if (r.bankInfo) {
|
||||
if (wsr.bankInfo) {
|
||||
withdrawalDetails = {
|
||||
type: WithdrawalType.TalerBankIntegrationApi,
|
||||
confirmed: r.timestampBankConfirmed ? true : false,
|
||||
confirmed: wsr.bankInfo.timestampBankConfirmed ? true : false,
|
||||
reservePub: wsr.reservePub,
|
||||
bankConfirmationUrl: r.bankInfo.confirmUrl,
|
||||
bankConfirmationUrl: wsr.bankInfo.confirmUrl,
|
||||
};
|
||||
} else {
|
||||
const exchangeDetails = await getExchangeDetails(
|
||||
@ -191,7 +177,7 @@ export async function getTransactions(
|
||||
transactions.push({
|
||||
type: TransactionType.Withdrawal,
|
||||
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
|
||||
amountRaw: Amounts.stringify(amountRaw),
|
||||
amountRaw: Amounts.stringify(wsr.rawWithdrawalAmount),
|
||||
withdrawalDetails,
|
||||
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
||||
pending: !wsr.timestampFinish,
|
||||
@ -205,56 +191,6 @@ export async function getTransactions(
|
||||
});
|
||||
});
|
||||
|
||||
// Report pending withdrawals based on reserves that
|
||||
// were created, but where the actual withdrawal group has
|
||||
// not started yet.
|
||||
tx.reserves.iter().forEachAsync(async (r) => {
|
||||
if (shouldSkipCurrency(transactionsRequest, r.currency)) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipSearch(transactionsRequest, [])) {
|
||||
return;
|
||||
}
|
||||
if (r.initialWithdrawalStarted) {
|
||||
return;
|
||||
}
|
||||
if (r.reserveStatus === ReserveRecordStatus.BankAborted) {
|
||||
return;
|
||||
}
|
||||
let withdrawalDetails: WithdrawalDetails;
|
||||
if (r.bankInfo) {
|
||||
withdrawalDetails = {
|
||||
type: WithdrawalType.TalerBankIntegrationApi,
|
||||
confirmed: false,
|
||||
reservePub: r.reservePub,
|
||||
bankConfirmationUrl: r.bankInfo.confirmUrl,
|
||||
};
|
||||
} else {
|
||||
withdrawalDetails = {
|
||||
type: WithdrawalType.ManualTransfer,
|
||||
reservePub: r.reservePub,
|
||||
exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
|
||||
};
|
||||
}
|
||||
transactions.push({
|
||||
type: TransactionType.Withdrawal,
|
||||
amountRaw: Amounts.stringify(r.instructedAmount),
|
||||
amountEffective: Amounts.stringify(
|
||||
r.initialDenomSel.totalCoinValue,
|
||||
),
|
||||
exchangeBaseUrl: r.exchangeBaseUrl,
|
||||
pending: true,
|
||||
timestamp: r.timestampCreated,
|
||||
withdrawalDetails: withdrawalDetails,
|
||||
transactionId: makeEventId(
|
||||
TransactionType.Withdrawal,
|
||||
r.initialWithdrawalGroupId,
|
||||
),
|
||||
frozen: false,
|
||||
...(r.lastError ? { error: r.lastError } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
tx.depositGroups.iter().forEachAsync(async (dg) => {
|
||||
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
|
||||
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
|
||||
@ -499,7 +435,7 @@ export async function retryTransaction(
|
||||
}
|
||||
case TransactionType.Withdrawal: {
|
||||
const withdrawalGroupId = rest[0];
|
||||
await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true });
|
||||
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true });
|
||||
break;
|
||||
}
|
||||
case TransactionType.Payment: {
|
||||
@ -536,7 +472,6 @@ export async function deleteTransaction(
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
reserves: x.reserves,
|
||||
tombstones: x.tombstones,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
@ -550,17 +485,6 @@ export async function deleteTransaction(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reserveRecord: ReserveRecord | undefined =
|
||||
await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
|
||||
withdrawalGroupId,
|
||||
);
|
||||
if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
|
||||
const reservePub = reserveRecord.reservePub;
|
||||
await tx.reserves.delete(reservePub);
|
||||
await tx.tombstones.put({
|
||||
id: TombstoneTag.DeleteReserve + ":" + reservePub,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (type === TransactionType.Payment) {
|
||||
const proposalId = rest[0];
|
||||
|
@ -19,20 +19,29 @@
|
||||
*/
|
||||
import {
|
||||
AbsoluteTime,
|
||||
AcceptManualWithdrawalResult,
|
||||
AcceptWithdrawalResponse,
|
||||
addPaytoQueryParams,
|
||||
AmountJson,
|
||||
AmountLike,
|
||||
Amounts,
|
||||
AmountString,
|
||||
BankWithdrawDetails,
|
||||
canonicalizeBaseUrl,
|
||||
codecForBankWithdrawalOperationPostResponse,
|
||||
codecForReserveStatus,
|
||||
codecForTalerConfigResponse,
|
||||
codecForWithdrawBatchResponse,
|
||||
codecForWithdrawOperationStatusResponse,
|
||||
codecForWithdrawResponse,
|
||||
DenomKeyType,
|
||||
Duration,
|
||||
durationFromSpec,
|
||||
durationFromSpec, encodeCrock,
|
||||
ExchangeListItem,
|
||||
ExchangeWithdrawRequest,
|
||||
ForcedDenomSel,
|
||||
getRandomBytes,
|
||||
j2s,
|
||||
LibtoolVersion,
|
||||
Logger,
|
||||
NotificationType,
|
||||
@ -45,8 +54,9 @@ import {
|
||||
VersionMatchResult,
|
||||
WithdrawBatchResponse,
|
||||
WithdrawResponse,
|
||||
WithdrawUriInfoResponse,
|
||||
WithdrawUriInfoResponse
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
|
||||
import {
|
||||
CoinRecord,
|
||||
CoinSourceType,
|
||||
@ -58,26 +68,42 @@ import {
|
||||
ExchangeRecord,
|
||||
OperationStatus,
|
||||
PlanchetRecord,
|
||||
WithdrawalGroupRecord,
|
||||
ReserveBankInfo,
|
||||
ReserveRecordStatus,
|
||||
WalletStoresV1,
|
||||
WithdrawalGroupRecord
|
||||
} from "../db.js";
|
||||
import {
|
||||
getErrorDetailFromException,
|
||||
makeErrorDetail,
|
||||
TalerError,
|
||||
TalerError
|
||||
} from "../errors.js";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { assertUnreachable } from "../util/assertUnreachable.js";
|
||||
import { walletCoreDebugFlags } from "../util/debugFlags.js";
|
||||
import {
|
||||
HttpRequestLibrary,
|
||||
readSuccessResponseJsonOrErrorCode,
|
||||
readSuccessResponseJsonOrThrow,
|
||||
throwUnexpectedRequestError
|
||||
} from "../util/http.js";
|
||||
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
|
||||
import {
|
||||
DbAccess,
|
||||
GetReadOnlyAccess
|
||||
} from "../util/query.js";
|
||||
import { RetryInfo } from "../util/retries.js";
|
||||
import {
|
||||
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
|
||||
WALLET_EXCHANGE_PROTOCOL_VERSION,
|
||||
WALLET_EXCHANGE_PROTOCOL_VERSION
|
||||
} from "../versions.js";
|
||||
import { guardOperationException } from "./common.js";
|
||||
import {
|
||||
getExchangeDetails,
|
||||
getExchangePaytoUri,
|
||||
getExchangeTrust,
|
||||
updateExchangeFromUrl
|
||||
} from "./exchanges.js";
|
||||
|
||||
/**
|
||||
* Logger for this file.
|
||||
@ -215,7 +241,7 @@ export function selectWithdrawalDenominations(
|
||||
for (const d of denoms) {
|
||||
let count = 0;
|
||||
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
|
||||
for (; ;) {
|
||||
for (;;) {
|
||||
if (Amounts.cmp(remaining, cost) < 0) {
|
||||
break;
|
||||
}
|
||||
@ -410,47 +436,42 @@ async function processPlanchetGenerate(
|
||||
return;
|
||||
}
|
||||
let ci = 0;
|
||||
let denomPubHash: string | undefined;
|
||||
let maybeDenomPubHash: string | undefined;
|
||||
for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
|
||||
const d = withdrawalGroup.denomsSel.selectedDenoms[di];
|
||||
if (coinIdx >= ci && coinIdx < ci + d.count) {
|
||||
denomPubHash = d.denomPubHash;
|
||||
maybeDenomPubHash = d.denomPubHash;
|
||||
break;
|
||||
}
|
||||
ci += d.count;
|
||||
}
|
||||
if (!denomPubHash) {
|
||||
if (!maybeDenomPubHash) {
|
||||
throw Error("invariant violated");
|
||||
}
|
||||
const denomPubHash = maybeDenomPubHash;
|
||||
|
||||
const { denom, reserve } = await ws.db
|
||||
const denom = await ws.db
|
||||
.mktx((x) => ({
|
||||
reserves: x.reserves,
|
||||
denominations: x.denominations,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
const denom = await tx.denominations.get([
|
||||
return ws.getDenomInfo(
|
||||
ws,
|
||||
tx,
|
||||
withdrawalGroup.exchangeBaseUrl,
|
||||
denomPubHash!,
|
||||
]);
|
||||
if (!denom) {
|
||||
throw Error("invariant violated");
|
||||
}
|
||||
const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
|
||||
if (!reserve) {
|
||||
throw Error("invariant violated");
|
||||
}
|
||||
return { denom, reserve };
|
||||
denomPubHash,
|
||||
);
|
||||
});
|
||||
checkDbInvariant(!!denom);
|
||||
const r = await ws.cryptoApi.createPlanchet({
|
||||
denomPub: denom.denomPub,
|
||||
feeWithdraw: denom.feeWithdraw,
|
||||
reservePriv: reserve.reservePriv,
|
||||
reservePub: reserve.reservePub,
|
||||
reservePriv: withdrawalGroup.reservePriv,
|
||||
reservePub: withdrawalGroup.reservePub,
|
||||
value: denom.value,
|
||||
coinIndex: coinIdx,
|
||||
secretSeed: withdrawalGroup.secretSeed,
|
||||
restrictAge: reserve.restrictAge,
|
||||
restrictAge: withdrawalGroup.restrictAge,
|
||||
});
|
||||
const newPlanchet: PlanchetRecord = {
|
||||
blindingKey: r.blindingKey,
|
||||
@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin(
|
||||
|
||||
const planchetCoinPub = planchet.coinPub;
|
||||
|
||||
// Check if this is the first time that the whole
|
||||
// withdrawal succeeded. If so, mark the withdrawal
|
||||
// group as finished.
|
||||
const firstSuccess = await ws.db
|
||||
.mktx((x) => ({
|
||||
coins: x.coins,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
reserves: x.reserves,
|
||||
planchets: x.planchets,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
@ -875,7 +898,8 @@ export async function updateWithdrawalDenoms(
|
||||
denom.verificationStatus === DenominationVerificationStatus.Unverified
|
||||
) {
|
||||
logger.trace(
|
||||
`Validating denomination (${current + 1}/${denominations.length
|
||||
`Validating denomination (${current + 1}/${
|
||||
denominations.length
|
||||
}) signature of ${denom.denomPubHash}`,
|
||||
);
|
||||
let valid = false;
|
||||
@ -960,7 +984,80 @@ async function reportWithdrawalError(
|
||||
ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
|
||||
}
|
||||
|
||||
export async function processWithdrawGroup(
|
||||
/**
|
||||
* Update the information about a reserve that is stored in the wallet
|
||||
* by querying the reserve's exchange.
|
||||
*
|
||||
* If the reserve have funds that are not allocated in a withdrawal group yet
|
||||
* and are big enough to withdraw with available denominations,
|
||||
* create a new withdrawal group for the remaining amount.
|
||||
*/
|
||||
async function queryReserve(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroupId: string,
|
||||
): Promise<{ ready: boolean }> {
|
||||
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
|
||||
withdrawalGroupId,
|
||||
});
|
||||
checkDbInvariant(!!withdrawalGroup);
|
||||
if (withdrawalGroup.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
|
||||
return { ready: true };
|
||||
}
|
||||
const reservePub = withdrawalGroup.reservePub;
|
||||
|
||||
const reserveUrl = new URL(
|
||||
`reserves/${reservePub}`,
|
||||
withdrawalGroup.exchangeBaseUrl,
|
||||
);
|
||||
reserveUrl.searchParams.set("timeout_ms", "30000");
|
||||
|
||||
logger.info(`querying reserve status via ${reserveUrl}`);
|
||||
|
||||
const resp = await ws.http.get(reserveUrl.href, {
|
||||
timeout: getReserveRequestTimeout(withdrawalGroup),
|
||||
});
|
||||
|
||||
const result = await readSuccessResponseJsonOrErrorCode(
|
||||
resp,
|
||||
codecForReserveStatus(),
|
||||
);
|
||||
|
||||
if (result.isError) {
|
||||
if (
|
||||
resp.status === 404 &&
|
||||
result.talerErrorResponse.code ===
|
||||
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
|
||||
) {
|
||||
ws.notify({
|
||||
type: NotificationType.ReserveNotYetFound,
|
||||
reservePub,
|
||||
});
|
||||
return { ready: false };
|
||||
} else {
|
||||
throwUnexpectedRequestError(resp, result.talerErrorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
logger.trace(`got reserve status ${j2s(result.response)}`);
|
||||
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
if (!wg) {
|
||||
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
|
||||
return;
|
||||
}
|
||||
wg.reserveStatus = ReserveRecordStatus.Dormant;
|
||||
await tx.withdrawalGroups.put(wg);
|
||||
});
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
export async function processWithdrawalGroup(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroupId: string,
|
||||
options: {
|
||||
@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl(
|
||||
.runReadOnly(async (tx) => {
|
||||
return tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
});
|
||||
|
||||
if (!withdrawalGroup) {
|
||||
// Withdrawal group doesn't exist yet, but reserve might exist
|
||||
// (and reference the yet to be created withdrawal group)
|
||||
const reservePub = await ws.db
|
||||
.mktx((x) => ({ reserves: x.reserves }))
|
||||
.runReadOnly(async (tx) => {
|
||||
const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
|
||||
withdrawalGroupId,
|
||||
);
|
||||
return r?.reservePub;
|
||||
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
|
||||
}
|
||||
|
||||
switch (withdrawalGroup.reserveStatus) {
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
await processReserveBankStatus(ws, withdrawalGroupId);
|
||||
return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
|
||||
forceNow: true,
|
||||
});
|
||||
if (!reservePub) {
|
||||
logger.warn(
|
||||
"withdrawal group doesn't exist (and reserve doesn't exist either)",
|
||||
);
|
||||
case ReserveRecordStatus.QueryingStatus: {
|
||||
const res = await queryReserve(ws, withdrawalGroupId);
|
||||
if (res.ready) {
|
||||
return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
|
||||
forceNow: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
return await ws.reserveOps.processReserve(ws, reservePub, { forceNow });
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
await processReserveBankStatus(ws, withdrawalGroupId);
|
||||
return;
|
||||
case ReserveRecordStatus.BankAborted:
|
||||
// FIXME
|
||||
return;
|
||||
case ReserveRecordStatus.Dormant:
|
||||
// We can try to withdraw, nothing needs to be done with the reserve.
|
||||
break;
|
||||
default:
|
||||
logger.warn(
|
||||
"unknown reserve record status:",
|
||||
withdrawalGroup.reserveStatus,
|
||||
);
|
||||
assertUnreachable(withdrawalGroup.reserveStatus);
|
||||
break;
|
||||
}
|
||||
|
||||
await ws.exchangeOps.updateExchangeFromUrl(
|
||||
@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl(
|
||||
.mktx((x) => ({
|
||||
coins: x.coins,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
reserves: x.reserves,
|
||||
planchets: x.planchets,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo(
|
||||
!versionMatch.compatible &&
|
||||
versionMatch.currentCmp === -1
|
||||
) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
|
||||
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
|
||||
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1308,3 +1422,456 @@ export async function getWithdrawalDetailsForUri(
|
||||
possibleExchanges: exchanges,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFundingPaytoUrisTx(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroupId: string,
|
||||
): Promise<string[]> {
|
||||
return await ws.db
|
||||
.mktx((x) => ({
|
||||
exchanges: x.exchanges,
|
||||
exchangeDetails: x.exchangeDetails,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payto URIs that can be used to fund a withdrawal operation.
|
||||
*/
|
||||
export async function getFundingPaytoUris(
|
||||
tx: GetReadOnlyAccess<{
|
||||
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
|
||||
exchanges: typeof WalletStoresV1.exchanges;
|
||||
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
|
||||
}>,
|
||||
withdrawalGroupId: string,
|
||||
): Promise<string[]> {
|
||||
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
checkDbInvariant(!!withdrawalGroup);
|
||||
const exchangeDetails = await getExchangeDetails(
|
||||
tx,
|
||||
withdrawalGroup.exchangeBaseUrl,
|
||||
);
|
||||
if (!exchangeDetails) {
|
||||
logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
|
||||
return [];
|
||||
}
|
||||
const plainPaytoUris =
|
||||
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
|
||||
if (!plainPaytoUris) {
|
||||
logger.error(
|
||||
`exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return plainPaytoUris.map((x) =>
|
||||
addPaytoQueryParams(x, {
|
||||
amount: Amounts.stringify(withdrawalGroup.instructedAmount),
|
||||
message: `Taler Withdrawal ${withdrawalGroup.reservePub}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getWithdrawalGroupRecordTx(
|
||||
db: DbAccess<typeof WalletStoresV1>,
|
||||
req: {
|
||||
withdrawalGroupId: string;
|
||||
},
|
||||
): Promise<WithdrawalGroupRecord | undefined> {
|
||||
return await db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
return tx.withdrawalGroups.get(req.withdrawalGroupId);
|
||||
});
|
||||
}
|
||||
|
||||
export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
|
||||
return Duration.max(
|
||||
{ d_ms: 60000 },
|
||||
Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
|
||||
);
|
||||
}
|
||||
|
||||
async function registerReserveWithBank(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroupId: string,
|
||||
): Promise<void> {
|
||||
const withdrawalGroup = await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
return await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
});
|
||||
switch (withdrawalGroup?.reserveStatus) {
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
const bankInfo = withdrawalGroup.bankInfo;
|
||||
if (!bankInfo) {
|
||||
return;
|
||||
}
|
||||
const bankStatusUrl = bankInfo.statusUrl;
|
||||
const httpResp = await ws.http.postJson(
|
||||
bankStatusUrl,
|
||||
{
|
||||
reserve_pub: withdrawalGroup.reservePub,
|
||||
selected_exchange: bankInfo.exchangePaytoUri,
|
||||
},
|
||||
{
|
||||
timeout: getReserveRequestTimeout(withdrawalGroup),
|
||||
},
|
||||
);
|
||||
await readSuccessResponseJsonOrThrow(
|
||||
httpResp,
|
||||
codecForBankWithdrawalOperationPostResponse(),
|
||||
);
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
switch (r.reserveStatus) {
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (!r.bankInfo) {
|
||||
throw Error("invariant failed");
|
||||
}
|
||||
r.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
|
||||
AbsoluteTime.now(),
|
||||
);
|
||||
r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
|
||||
r.operationStatus = OperationStatus.Pending;
|
||||
r.retryInfo = RetryInfo.reset();
|
||||
await tx.withdrawalGroups.put(r);
|
||||
});
|
||||
ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
|
||||
return processReserveBankStatus(ws, withdrawalGroupId);
|
||||
}
|
||||
|
||||
async function processReserveBankStatus(
|
||||
ws: InternalWalletState,
|
||||
withdrawalGroupId: string,
|
||||
): Promise<void> {
|
||||
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
|
||||
withdrawalGroupId,
|
||||
});
|
||||
switch (withdrawalGroup?.reserveStatus) {
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl;
|
||||
if (!bankStatusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusResp = await ws.http.get(bankStatusUrl, {
|
||||
timeout: getReserveRequestTimeout(withdrawalGroup),
|
||||
});
|
||||
const status = await readSuccessResponseJsonOrThrow(
|
||||
statusResp,
|
||||
codecForWithdrawOperationStatusResponse(),
|
||||
);
|
||||
|
||||
if (status.aborted) {
|
||||
logger.info("bank aborted the withdrawal");
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
switch (r.reserveStatus) {
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (!r.bankInfo) {
|
||||
throw Error("invariant failed");
|
||||
}
|
||||
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
|
||||
r.bankInfo.timestampBankConfirmed = now;
|
||||
r.reserveStatus = ReserveRecordStatus.BankAborted;
|
||||
r.operationStatus = OperationStatus.Finished;
|
||||
r.retryInfo = RetryInfo.reset();
|
||||
await tx.withdrawalGroups.put(r);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank still needs to know our reserve info
|
||||
if (!status.selection_done) {
|
||||
await registerReserveWithBank(ws, withdrawalGroupId);
|
||||
return await processReserveBankStatus(ws, withdrawalGroupId);
|
||||
}
|
||||
|
||||
// FIXME: Why do we do this?!
|
||||
if (withdrawalGroup.reserveStatus === ReserveRecordStatus.RegisteringBank) {
|
||||
await registerReserveWithBank(ws, withdrawalGroupId);
|
||||
return await processReserveBankStatus(ws, withdrawalGroupId);
|
||||
}
|
||||
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
// Re-check reserve status within transaction
|
||||
switch (r.reserveStatus) {
|
||||
case ReserveRecordStatus.RegisteringBank:
|
||||
case ReserveRecordStatus.WaitConfirmBank:
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (status.transfer_done) {
|
||||
logger.info("withdrawal: transfer confirmed by bank.");
|
||||
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
|
||||
if (!r.bankInfo) {
|
||||
throw Error("invariant failed");
|
||||
}
|
||||
r.bankInfo.timestampBankConfirmed = now;
|
||||
r.reserveStatus = ReserveRecordStatus.QueryingStatus;
|
||||
r.operationStatus = OperationStatus.Pending;
|
||||
r.retryInfo = RetryInfo.reset();
|
||||
} else {
|
||||
logger.info("withdrawal: transfer not yet confirmed by bank");
|
||||
if (r.bankInfo) {
|
||||
r.bankInfo.confirmUrl = status.confirm_transfer_url;
|
||||
}
|
||||
r.retryInfo = RetryInfo.increment(r.retryInfo);
|
||||
}
|
||||
await tx.withdrawalGroups.put(r);
|
||||
});
|
||||
}
|
||||
|
||||
export async function internalCreateWithdrawalGroup(
|
||||
ws: InternalWalletState,
|
||||
args: {
|
||||
reserveStatus: ReserveRecordStatus;
|
||||
amount: AmountJson;
|
||||
bankInfo?: ReserveBankInfo;
|
||||
exchangeBaseUrl: string;
|
||||
forcedDenomSel?: ForcedDenomSel;
|
||||
reserveKeyPair?: EddsaKeypair;
|
||||
restrictAge?: number;
|
||||
},
|
||||
): Promise<WithdrawalGroupRecord> {
|
||||
const reserveKeyPair =
|
||||
args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
|
||||
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
|
||||
const secretSeed = encodeCrock(getRandomBytes(32));
|
||||
const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
|
||||
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
|
||||
const amount = args.amount;
|
||||
|
||||
await updateWithdrawalDenoms(ws, canonExchange);
|
||||
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
|
||||
|
||||
let initialDenomSel: DenomSelectionState;
|
||||
const denomSelUid = encodeCrock(getRandomBytes(16));
|
||||
if (args.forcedDenomSel) {
|
||||
logger.warn("using forced denom selection");
|
||||
initialDenomSel = selectForcedWithdrawalDenominations(
|
||||
amount,
|
||||
denoms,
|
||||
args.forcedDenomSel,
|
||||
);
|
||||
} else {
|
||||
initialDenomSel = selectWithdrawalDenominations(amount, denoms);
|
||||
}
|
||||
|
||||
const withdrawalGroup: WithdrawalGroupRecord = {
|
||||
denomSelUid,
|
||||
denomsSel: initialDenomSel,
|
||||
exchangeBaseUrl: canonExchange,
|
||||
instructedAmount: amount,
|
||||
timestampStart: now,
|
||||
lastError: undefined,
|
||||
operationStatus: OperationStatus.Pending,
|
||||
rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
|
||||
secretSeed,
|
||||
reservePriv: reserveKeyPair.priv,
|
||||
reservePub: reserveKeyPair.pub,
|
||||
reserveStatus: args.reserveStatus,
|
||||
retryInfo: RetryInfo.reset(),
|
||||
withdrawalGroupId,
|
||||
bankInfo: args.bankInfo,
|
||||
restrictAge: args.restrictAge,
|
||||
senderWire: undefined,
|
||||
timestampFinish: undefined,
|
||||
};
|
||||
|
||||
const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
|
||||
const exchangeDetails = exchangeInfo.exchangeDetails;
|
||||
if (!exchangeDetails) {
|
||||
logger.trace(exchangeDetails);
|
||||
throw Error("exchange not updated");
|
||||
}
|
||||
const { isAudited, isTrusted } = await getExchangeTrust(
|
||||
ws,
|
||||
exchangeInfo.exchange,
|
||||
);
|
||||
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
exchanges: x.exchanges,
|
||||
exchangeDetails: x.exchangeDetails,
|
||||
exchangeTrust: x.exchangeTrust,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
await tx.withdrawalGroups.add(withdrawalGroup);
|
||||
|
||||
if (!isAudited && !isTrusted) {
|
||||
await tx.exchangeTrust.put({
|
||||
currency: amount.currency,
|
||||
exchangeBaseUrl: canonExchange,
|
||||
exchangeMasterPub: exchangeDetails.masterPublicKey,
|
||||
uids: [encodeCrock(getRandomBytes(32))],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return withdrawalGroup;
|
||||
}
|
||||
|
||||
export async function acceptWithdrawalFromUri(
|
||||
ws: InternalWalletState,
|
||||
req: {
|
||||
talerWithdrawUri: string;
|
||||
selectedExchange: string;
|
||||
forcedDenomSel?: ForcedDenomSel;
|
||||
restrictAge?: number;
|
||||
},
|
||||
): Promise<AcceptWithdrawalResponse> {
|
||||
await updateExchangeFromUrl(ws, req.selectedExchange);
|
||||
const withdrawInfo = await getBankWithdrawalInfo(
|
||||
ws.http,
|
||||
req.talerWithdrawUri,
|
||||
);
|
||||
const exchangePaytoUri = await getExchangePaytoUri(
|
||||
ws,
|
||||
req.selectedExchange,
|
||||
withdrawInfo.wireTypes,
|
||||
);
|
||||
|
||||
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
|
||||
amount: withdrawInfo.amount,
|
||||
exchangeBaseUrl: req.selectedExchange,
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
reserveStatus: ReserveRecordStatus.RegisteringBank,
|
||||
bankInfo: {
|
||||
exchangePaytoUri,
|
||||
statusUrl: withdrawInfo.extractedStatusUrl,
|
||||
confirmUrl: withdrawInfo.confirmTransferUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
|
||||
|
||||
// We do this here, as the reserve should be registered before we return,
|
||||
// so that we can redirect the user to the bank's status page.
|
||||
await processReserveBankStatus(ws, withdrawalGroupId);
|
||||
const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
|
||||
withdrawalGroupId,
|
||||
});
|
||||
if (
|
||||
processedWithdrawalGroup?.reserveStatus === ReserveRecordStatus.BankAborted
|
||||
) {
|
||||
throw TalerError.fromDetail(
|
||||
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
// Start withdrawal in the background.
|
||||
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
|
||||
(err) => {
|
||||
logger.error("Processing withdrawal (after creation) failed:", err);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
reservePub: withdrawalGroup.reservePub,
|
||||
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual withdrawal operation.
|
||||
*
|
||||
* Adds the corresponding exchange as a trusted exchange if it is neither
|
||||
* audited nor trusted already.
|
||||
*
|
||||
* Asynchronously starts the withdrawal.
|
||||
*/
|
||||
export async function createManualWithdrawal(
|
||||
ws: InternalWalletState,
|
||||
req: {
|
||||
exchangeBaseUrl: string;
|
||||
amount: AmountLike;
|
||||
restrictAge?: number;
|
||||
forcedDenomSel?: ForcedDenomSel;
|
||||
},
|
||||
): Promise<AcceptManualWithdrawalResult> {
|
||||
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
|
||||
amount: Amounts.jsonifyAmount(req.amount),
|
||||
exchangeBaseUrl: req.exchangeBaseUrl,
|
||||
bankInfo: undefined,
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
restrictAge: req.restrictAge,
|
||||
reserveStatus: ReserveRecordStatus.QueryingStatus,
|
||||
});
|
||||
|
||||
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
|
||||
|
||||
const exchangePaytoUris = await ws.db
|
||||
.mktx((x) => ({
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
exchanges: x.exchanges,
|
||||
exchangeDetails: x.exchangeDetails,
|
||||
exchangeTrust: x.exchangeTrust,
|
||||
}))
|
||||
.runReadWrite(async (tx) => {
|
||||
return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
|
||||
});
|
||||
|
||||
// Start withdrawal in the background.
|
||||
await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
|
||||
(err) => {
|
||||
logger.error("Processing withdrawal (after creation) failed:", err);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
reservePub: withdrawalGroup.reservePub,
|
||||
exchangePaytoUris: exchangePaytoUris,
|
||||
};
|
||||
}
|
||||
|
@ -40,7 +40,6 @@ export enum PendingTaskType {
|
||||
ProposalChoice = "proposal-choice",
|
||||
ProposalDownload = "proposal-download",
|
||||
Refresh = "refresh",
|
||||
Reserve = "reserve",
|
||||
Recoup = "recoup",
|
||||
RefundQuery = "refund-query",
|
||||
TipPickup = "tip-pickup",
|
||||
@ -60,7 +59,6 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
|
||||
| PendingProposalDownloadTask
|
||||
| PendingRefreshTask
|
||||
| PendingRefundQueryTask
|
||||
| PendingReserveTask
|
||||
| PendingTipPickupTask
|
||||
| PendingWithdrawTask
|
||||
| PendingRecoupTask
|
||||
@ -103,22 +101,6 @@ export enum ReserveType {
|
||||
TalerBankWithdraw = "taler-bank-withdraw",
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of processing a reserve.
|
||||
*
|
||||
* Does *not* include the withdrawal operation that might result
|
||||
* from this.
|
||||
*/
|
||||
export interface PendingReserveTask {
|
||||
type: PendingTaskType.Reserve;
|
||||
retryInfo: RetryInfo | undefined;
|
||||
stage: ReserveRecordStatus;
|
||||
timestampCreated: TalerProtocolTimestamp;
|
||||
reserveType: ReserveType;
|
||||
reservePub: string;
|
||||
bankWithdrawConfirmUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of an ongoing withdrawal operation.
|
||||
*/
|
||||
|
@ -107,7 +107,6 @@ import {
|
||||
MerchantOperations,
|
||||
NotificationListener,
|
||||
RecoupOperations,
|
||||
ReserveOperations,
|
||||
} from "./internal-wallet-state.js";
|
||||
import { exportBackup } from "./operations/backup/export.js";
|
||||
import {
|
||||
@ -167,12 +166,6 @@ import {
|
||||
prepareRefund,
|
||||
processPurchaseQueryRefund,
|
||||
} from "./operations/refund.js";
|
||||
import {
|
||||
createReserve,
|
||||
createTalerWithdrawReserve,
|
||||
getFundingPaytoUris,
|
||||
processReserve,
|
||||
} from "./operations/reserves.js";
|
||||
import {
|
||||
runIntegrationTest,
|
||||
testPay,
|
||||
@ -185,9 +178,12 @@ import {
|
||||
retryTransaction,
|
||||
} from "./operations/transactions.js";
|
||||
import {
|
||||
acceptWithdrawalFromUri,
|
||||
createManualWithdrawal,
|
||||
getExchangeWithdrawalInfo,
|
||||
getFundingPaytoUrisTx,
|
||||
getWithdrawalDetailsForUri,
|
||||
processWithdrawGroup,
|
||||
processWithdrawalGroup as processWithdrawalGroup,
|
||||
} from "./operations/withdraw.js";
|
||||
import {
|
||||
PendingOperationsResponse,
|
||||
@ -258,11 +254,8 @@ async function processOnePendingOperation(
|
||||
case PendingTaskType.Refresh:
|
||||
await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
|
||||
break;
|
||||
case PendingTaskType.Reserve:
|
||||
await processReserve(ws, pending.reservePub, { forceNow });
|
||||
break;
|
||||
case PendingTaskType.Withdraw:
|
||||
await processWithdrawGroup(ws, pending.withdrawalGroupId, { forceNow });
|
||||
await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow });
|
||||
break;
|
||||
case PendingTaskType.ProposalDownload:
|
||||
await processDownloadProposal(ws, pending.proposalId, { forceNow });
|
||||
@ -464,40 +457,6 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reserve for a manual withdrawal.
|
||||
*
|
||||
* Adds the corresponding exchange as a trusted exchange if it is neither
|
||||
* audited nor trusted already.
|
||||
*/
|
||||
async function acceptManualWithdrawal(
|
||||
ws: InternalWalletState,
|
||||
exchangeBaseUrl: string,
|
||||
amount: AmountJson,
|
||||
restrictAge?: number,
|
||||
): Promise<AcceptManualWithdrawalResult> {
|
||||
try {
|
||||
const resp = await createReserve(ws, {
|
||||
amount,
|
||||
exchange: exchangeBaseUrl,
|
||||
restrictAge,
|
||||
});
|
||||
const exchangePaytoUris = await ws.db
|
||||
.mktx((x) => ({
|
||||
exchanges: x.exchanges,
|
||||
exchangeDetails: x.exchangeDetails,
|
||||
reserves: x.reserves,
|
||||
}))
|
||||
.runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
|
||||
return {
|
||||
reservePub: resp.reservePub,
|
||||
exchangePaytoUris,
|
||||
};
|
||||
} finally {
|
||||
ws.latch.trigger();
|
||||
}
|
||||
}
|
||||
|
||||
async function getExchangeTos(
|
||||
ws: InternalWalletState,
|
||||
exchangeBaseUrl: string,
|
||||
@ -552,6 +511,10 @@ async function getExchangeTos(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List bank accounts known to the wallet from
|
||||
* previous withdrawals.
|
||||
*/
|
||||
async function listKnownBankAccounts(
|
||||
ws: InternalWalletState,
|
||||
currency?: string,
|
||||
@ -559,12 +522,13 @@ async function listKnownBankAccounts(
|
||||
const accounts: PaytoUri[] = [];
|
||||
await ws.db
|
||||
.mktx((x) => ({
|
||||
reserves: x.reserves,
|
||||
withdrawalGroups: x.withdrawalGroups,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
const reservesRecords = await tx.reserves.iter().toArray();
|
||||
for (const r of reservesRecords) {
|
||||
if (currency && currency !== r.currency) {
|
||||
const withdrawalGroups = await tx.withdrawalGroups.iter().toArray();
|
||||
for (const r of withdrawalGroups) {
|
||||
const amount = r.rawWithdrawalAmount;
|
||||
if (currency && currency !== amount.currency) {
|
||||
continue;
|
||||
}
|
||||
const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined;
|
||||
@ -614,31 +578,6 @@ async function getExchanges(
|
||||
return { exchanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the wallet that the status of a reserve has changed (e.g. due to a
|
||||
* confirmation from the bank.).
|
||||
*/
|
||||
export async function handleNotifyReserve(
|
||||
ws: InternalWalletState,
|
||||
): Promise<void> {
|
||||
const reserves = await ws.db
|
||||
.mktx((x) => ({
|
||||
reserves: x.reserves,
|
||||
}))
|
||||
.runReadOnly(async (tx) => {
|
||||
return tx.reserves.iter().toArray();
|
||||
});
|
||||
for (const r of reserves) {
|
||||
if (r.reserveStatus === ReserveRecordStatus.WaitConfirmBank) {
|
||||
try {
|
||||
processReserve(ws, r.reservePub);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setCoinSuspended(
|
||||
ws: InternalWalletState,
|
||||
coinPub: string,
|
||||
@ -817,12 +756,11 @@ async function dispatchRequestInternal(
|
||||
}
|
||||
case "acceptManualWithdrawal": {
|
||||
const req = codecForAcceptManualWithdrawalRequet().decode(payload);
|
||||
const res = await acceptManualWithdrawal(
|
||||
ws,
|
||||
req.exchangeBaseUrl,
|
||||
Amounts.parseOrThrow(req.amount),
|
||||
req.restrictAge,
|
||||
);
|
||||
const res = await createManualWithdrawal(ws, {
|
||||
amount: Amounts.parseOrThrow(req.amount),
|
||||
exchangeBaseUrl: req.exchangeBaseUrl,
|
||||
restrictAge: req.restrictAge,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
case "getWithdrawalDetailsForAmount": {
|
||||
@ -856,15 +794,12 @@ async function dispatchRequestInternal(
|
||||
case "acceptBankIntegratedWithdrawal": {
|
||||
const req =
|
||||
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
|
||||
return await createTalerWithdrawReserve(
|
||||
ws,
|
||||
req.talerWithdrawUri,
|
||||
req.exchangeBaseUrl,
|
||||
{
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
restrictAge: req.restrictAge,
|
||||
},
|
||||
);
|
||||
return await acceptWithdrawalFromUri(ws, {
|
||||
selectedExchange: req.exchangeBaseUrl,
|
||||
talerWithdrawUri: req.talerWithdrawUri,
|
||||
forcedDenomSel: req.forcedDenomSel,
|
||||
restrictAge: req.restrictAge,
|
||||
});
|
||||
}
|
||||
case "getExchangeTos": {
|
||||
const req = codecForGetExchangeTosRequest().decode(payload);
|
||||
@ -1033,7 +968,10 @@ async function dispatchRequestInternal(
|
||||
req.exchange,
|
||||
amount,
|
||||
);
|
||||
const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
|
||||
const wres = await createManualWithdrawal(ws, {
|
||||
amount: amount,
|
||||
exchangeBaseUrl: req.exchange,
|
||||
});
|
||||
const paytoUri = details.paytoUris[0];
|
||||
const pt = parsePaytoUri(paytoUri);
|
||||
if (!pt) {
|
||||
@ -1229,10 +1167,6 @@ class InternalWalletStateImpl implements InternalWalletState {
|
||||
getMerchantInfo,
|
||||
};
|
||||
|
||||
reserveOps: ReserveOperations = {
|
||||
processReserve,
|
||||
};
|
||||
|
||||
// FIXME: Use an LRU cache here.
|
||||
private denomCache: Record<string, DenomInfo> = {};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user