aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core')
-rw-r--r--packages/taler-wallet-core/package.json5
-rw-r--r--packages/taler-wallet-core/src/attention.ts (renamed from packages/taler-wallet-core/src/operations/attention.ts)84
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts (renamed from packages/taler-wallet-core/src/operations/backup/index.ts)606
-rw-r--r--packages/taler-wallet-core/src/backup/state.ts (renamed from packages/taler-wallet-core/src/operations/backup/state.ts)2
-rw-r--r--packages/taler-wallet-core/src/balance.ts797
-rw-r--r--packages/taler-wallet-core/src/coinSelection.test.ts (renamed from packages/taler-wallet-core/src/util/coinSelection.test.ts)178
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts1258
-rw-r--r--packages/taler-wallet-core/src/common.ts (renamed from packages/taler-wallet-core/src/operations/common.ts)830
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts42
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts14
-rw-r--r--packages/taler-wallet-core/src/db.ts473
-rw-r--r--packages/taler-wallet-core/src/dbless.ts61
-rw-r--r--packages/taler-wallet-core/src/denomSelection.ts199
-rw-r--r--packages/taler-wallet-core/src/denominations.test.ts (renamed from packages/taler-wallet-core/src/util/denominations.test.ts)0
-rw-r--r--packages/taler-wallet-core/src/denominations.ts (renamed from packages/taler-wallet-core/src/util/denominations.ts)11
-rw-r--r--packages/taler-wallet-core/src/deposits.ts (renamed from packages/taler-wallet-core/src/operations/deposits.ts)1364
-rw-r--r--packages/taler-wallet-core/src/dev-experiments.ts111
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts2581
-rw-r--r--packages/taler-wallet-core/src/host-common.ts6
-rw-r--r--packages/taler-wallet-core/src/host-impl.node.ts52
-rw-r--r--packages/taler-wallet-core/src/host-impl.qtart.ts31
-rw-r--r--packages/taler-wallet-core/src/index.ts50
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.test.ts (renamed from packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts)8
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts (renamed from packages/taler-wallet-core/src/util/instructedAmountConversion.ts)83
-rw-r--r--packages/taler-wallet-core/src/internal-wallet-state.ts227
-rw-r--r--packages/taler-wallet-core/src/observable-wrappers.ts295
-rw-r--r--packages/taler-wallet-core/src/operations/README.md7
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts599
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts1433
-rw-r--r--packages/taler-wallet-core/src/operations/merchants.ts66
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts927
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts1170
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts803
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts1449
-rw-r--r--packages/taler-wallet-core/src/operations/reward.ts644
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts2766
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts (renamed from packages/taler-wallet-core/src/operations/pay-merchant.ts)2482
-rw-r--r--packages/taler-wallet-core/src/pay-peer-common.ts (renamed from packages/taler-wallet-core/src/operations/pay-peer-common.ts)92
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-credit.ts (renamed from packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts)919
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts1019
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts (renamed from packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts)768
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts1322
-rw-r--r--packages/taler-wallet-core/src/pending-types.ts252
-rw-r--r--packages/taler-wallet-core/src/query.ts (renamed from packages/taler-wallet-core/src/util/query.ts)472
-rw-r--r--packages/taler-wallet-core/src/recoup.ts (renamed from packages/taler-wallet-core/src/operations/recoup.ts)372
-rw-r--r--packages/taler-wallet-core/src/refresh.ts1883
-rw-r--r--packages/taler-wallet-core/src/remote.ts18
-rw-r--r--packages/taler-wallet-core/src/shepherd.ts1128
-rw-r--r--packages/taler-wallet-core/src/testing.ts (renamed from packages/taler-wallet-core/src/operations/testing.ts)586
-rw-r--r--packages/taler-wallet-core/src/transactions.ts (renamed from packages/taler-wallet-core/src/operations/transactions.ts)1631
-rw-r--r--packages/taler-wallet-core/src/util/assertUnreachable.ts19
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts1236
-rw-r--r--packages/taler-wallet-core/src/util/invariants.ts46
-rw-r--r--packages/taler-wallet-core/src/util/promiseUtils.ts60
-rw-r--r--packages/taler-wallet-core/src/util/timer.ts213
-rw-r--r--packages/taler-wallet-core/src/versions.ts22
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts365
-rw-r--r--packages/taler-wallet-core/src/wallet.ts1909
-rw-r--r--packages/taler-wallet-core/src/withdraw.test.ts (renamed from packages/taler-wallet-core/src/operations/withdraw.test.ts)10
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts3604
-rw-r--r--packages/taler-wallet-core/tsconfig.json4
61 files changed, 21671 insertions, 17993 deletions
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index 4825de2c9..46b3cef4e 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-core",
- "version": "0.9.3-dev.34",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.18.0"
@@ -39,6 +39,9 @@
},
"./remote": {
"default": "./lib/remote.js"
+ },
+ "./dbless": {
+ "default": "./lib/dbless.js"
}
},
"imports": {
diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/attention.ts
index 92d69e93e..7a52ceaa3 100644
--- a/packages/taler-wallet-core/src/operations/attention.ts
+++ b/packages/taler-wallet-core/src/attention.ts
@@ -18,30 +18,28 @@
* Imports.
*/
import {
- AbsoluteTime,
AttentionInfo,
Logger,
- TalerProtocolTimestamp,
TalerPreciseTimestamp,
UserAttentionByIdRequest,
UserAttentionPriority,
+ UserAttentionUnreadList,
UserAttentionsCountResponse,
UserAttentionsRequest,
UserAttentionsResponse,
- UserAttentionUnreadList,
} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { timestampPreciseFromDb, timestampPreciseToDb } from "../index.js";
+import { timestampPreciseFromDb, timestampPreciseToDb } from "./db.js";
+import { WalletExecutionContext } from "./wallet.js";
const logger = new Logger("operations/attention.ts");
export async function getUserAttentionsUnreadCount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: UserAttentionsRequest,
): Promise<UserAttentionsCountResponse> {
- const total = await ws.db
- .mktx((x) => [x.userAttention])
- .runReadOnly(async (tx) => {
+ const total = await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
let count = 0;
await tx.userAttention.iter().forEach((x) => {
if (
@@ -54,18 +52,19 @@ export async function getUserAttentionsUnreadCount(
});
return count;
- });
+ },
+ );
return { total };
}
export async function getUserAttentions(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: UserAttentionsRequest,
): Promise<UserAttentionsResponse> {
- return await ws.db
- .mktx((x) => [x.userAttention])
- .runReadOnly(async (tx) => {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
const pending: UserAttentionUnreadList = [];
await tx.userAttention.iter().forEach((x) => {
if (
@@ -81,65 +80,60 @@ export async function getUserAttentions(
});
return { pending };
- });
+ },
+ );
}
export async function markAttentionRequestAsRead(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: UserAttentionByIdRequest,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.userAttention])
- .runReadWrite(async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- tx.userAttention.put({
- ...ua,
- read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- });
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ tx.userAttention.put({
+ ...ua,
+ read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
});
+ });
}
/**
* the wallet need the user attention to complete a task
* internal API
*
- * @param ws
+ * @param wex
* @param info
*/
export async function addAttentionRequest(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
info: AttentionInfo,
entityId: string,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.userAttention])
- .runReadWrite(async (tx) => {
- await tx.userAttention.put({
- info,
- entityId,
- created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- read: undefined,
- });
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ await tx.userAttention.put({
+ info,
+ entityId,
+ created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ read: undefined,
});
+ });
}
/**
* user completed the task, attention request is not needed
* internal API
*
- * @param ws
+ * @param wex
* @param created
*/
export async function removeAttentionRequest(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: UserAttentionByIdRequest,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.userAttention])
- .runReadWrite(async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- await tx.userAttention.delete([req.entityId, req.type]);
- });
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ await tx.userAttention.delete([req.entityId, req.type]);
+ });
}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
index 7a2771c57..15904b470 100644
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ b/packages/taler-wallet-core/src/backup/index.ts
@@ -26,46 +26,42 @@
*/
import {
AbsoluteTime,
- AmountString,
AttentionType,
BackupRecovery,
Codec,
- DenomKeyType,
+ Duration,
EddsaKeyPair,
HttpStatusCode,
Logger,
PreparePayResult,
- PreparePayResultType,
+ ProviderInfo,
+ ProviderPaymentStatus,
RecoveryLoadRequest,
RecoveryMergeStrategy,
TalerError,
TalerErrorCode,
- TalerErrorDetail,
TalerPreciseTimestamp,
URL,
buildCodecForObject,
buildCodecForUnion,
bytesToString,
canonicalJson,
- canonicalizeBaseUrl,
- codecForAmountString,
+ checkDbInvariant,
+ checkLogicInvariant,
codecForBoolean,
codecForConstString,
codecForList,
- codecForNumber,
codecForString,
+ codecForSyncTermsOfServiceResponse,
codecOptional,
decodeCrock,
- durationFromSpec,
eddsaGetPublic,
encodeCrock,
getRandomBytes,
hash,
- hashDenomPub,
j2s,
kdf,
notEmpty,
- rsaBlind,
secretbox,
secretbox_open,
stringToBytes,
@@ -75,34 +71,25 @@ import {
readTalerErrorResponse,
} from "@gnu-taler/taler-util/http";
import { gunzipSync, gzipSync } from "fflate";
-import { TalerCryptoInterface } from "../../crypto/cryptoImplementation.js";
+import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+} from "../common.js";
import {
BackupProviderRecord,
BackupProviderState,
BackupProviderStateTag,
- BackupProviderTerms,
ConfigRecord,
ConfigRecordKey,
WalletBackupConfState,
+ WalletDbReadOnlyTransaction,
timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
timestampPreciseToDb,
-} from "../../db.js";
-import { InternalWalletState } from "../../internal-wallet-state.js";
-import { assertUnreachable } from "../../util/assertUnreachable.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TaskIdentifiers,
-} from "../common.js";
-import { checkPaymentByProposalId, preparePayForUri } from "../pay-merchant.js";
-import { WalletStoresV1 } from "../../db.js";
-import { GetReadOnlyAccess } from "../../util/query.js";
+} from "../db.js";
+import { preparePayForUri } from "../pay-merchant.js";
+import { InternalWalletState, WalletExecutionContext } from "../wallet.js";
const logger = new Logger("operations/backup.ts");
@@ -185,20 +172,21 @@ function getNextBackupTimestamp(): TalerPreciseTimestamp {
return AbsoluteTime.toPreciseTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
- durationFromSpec({ minutes: 5 }),
+ Duration.fromSpec({ minutes: 5 }),
),
);
}
async function runBackupCycleForProvider(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
args: BackupForProviderArgs,
): Promise<TaskRunResult> {
- const provider = await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadOnly(async (tx) => {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
return tx.backupProviders.get(args.backupProviderBaseUrl);
- });
+ },
+ );
if (!provider) {
logger.warn("provider disappeared");
@@ -208,7 +196,7 @@ async function runBackupCycleForProvider(
//const backupJson = await exportBackup(ws);
// FIXME: re-implement backup
const backupJson = {};
- const backupConfig = await provideBackupState(ws);
+ const backupConfig = await provideBackupState(wex);
const encBackup = await encryptBackup(backupConfig, backupJson);
const currentBackupHash = hash(encBackup);
@@ -220,7 +208,7 @@ async function runBackupCycleForProvider(
logger.trace(`trying to upload backup to ${provider.baseUrl}`);
logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
- const syncSigResp = await ws.cryptoApi.makeSyncSignature({
+ const syncSigResp = await wex.cryptoApi.makeSyncSignature({
newHash: encodeCrock(currentBackupHash),
oldHash: provider.lastBackupHash,
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
@@ -237,16 +225,16 @@ async function runBackupCycleForProvider(
accountBackupUrl.searchParams.set("fresh", "yes");
}
- const resp = await ws.http.fetch(accountBackupUrl.href, {
+ const resp = await wex.http.fetch(accountBackupUrl.href, {
method: "POST",
body: encBackup,
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSigResp.sig,
- "if-none-match": newHash,
+ "if-none-match": JSON.stringify(newHash),
...(provider.lastBackupHash
? {
- "if-match": provider.lastBackupHash,
+ "if-match": JSON.stringify(provider.lastBackupHash),
}
: {}),
},
@@ -255,9 +243,9 @@ async function runBackupCycleForProvider(
logger.trace(`sync response status: ${resp.status}`);
if (resp.status === HttpStatusCode.NotModified) {
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
return;
@@ -270,9 +258,10 @@ async function runBackupCycleForProvider(
nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
};
await tx.backupProviders.put(prov);
- });
+ },
+ );
- removeAttentionRequest(ws, {
+ removeAttentionRequest(wex, {
entityId: provider.baseUrl,
type: AttentionType.BackupUnpaid,
});
@@ -292,7 +281,7 @@ async function runBackupCycleForProvider(
//FIXME: check download errors
let res: PreparePayResult | undefined = undefined;
try {
- res = await preparePayForUri(ws, talerUri);
+ res = await preparePayForUri(wex, talerUri);
} catch (e) {
const error = TalerError.fromException(e);
if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
@@ -303,9 +292,9 @@ async function runBackupCycleForProvider(
if (res === undefined) {
//claimed
- await ws.db
- .mktx((x) => [x.backupProviders, x.operationRetries])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
logger.warn("backup provider not found anymore");
@@ -316,34 +305,37 @@ async function runBackupCycleForProvider(
tag: BackupProviderStateTag.Retrying,
};
await tx.backupProviders.put(prov);
- });
+ },
+ );
- return {
- type: TaskRunResultType.Pending,
- };
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
}
const result = res;
- await ws.db
- .mktx((x) => [x.backupProviders, x.operationRetries])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
logger.warn("backup provider not found anymore");
return;
}
- const opId = TaskIdentifiers.forBackup(prov);
- //await scheduleRetryInTx(ws, tx, opId);
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
prov.currentPaymentProposalId = result.proposalId;
prov.shouldRetryFreshProposal = false;
prov.state = {
tag: BackupProviderStateTag.Retrying,
};
await tx.backupProviders.put(prov);
- });
+ },
+ );
addAttentionRequest(
- ws,
+ wex,
{
type: AttentionType.BackupUnpaid,
provider_base_url: provider.baseUrl,
@@ -352,15 +344,16 @@ async function runBackupCycleForProvider(
provider.baseUrl,
);
- return {
- type: TaskRunResultType.Pending,
- };
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
}
if (resp.status === HttpStatusCode.NoContent) {
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
return;
@@ -374,9 +367,10 @@ async function runBackupCycleForProvider(
nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
};
await tx.backupProviders.put(prov);
- });
+ },
+ );
- removeAttentionRequest(ws, {
+ removeAttentionRequest(wex, {
entityId: provider.baseUrl,
type: AttentionType.BackupUnpaid,
});
@@ -389,13 +383,13 @@ async function runBackupCycleForProvider(
if (resp.status === HttpStatusCode.Conflict) {
logger.info("conflicting backup found");
const backupEnc = new Uint8Array(await resp.bytes());
- const backupConfig = await provideBackupState(ws);
+ const backupConfig = await provideBackupState(wex);
// const blob = await decryptBackup(backupConfig, backupEnc);
// FIXME: Re-implement backup import with merging
// await importBackup(ws, blob, cryptoData);
- await ws.db
- .mktx((x) => [x.backupProviders, x.operationRetries])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
logger.warn("backup provider not found anymore");
@@ -410,10 +404,11 @@ async function runBackupCycleForProvider(
tag: BackupProviderStateTag.Retrying,
};
await tx.backupProviders.put(prov);
- });
+ },
+ );
logger.info("processed existing backup");
// Now upload our own, merged backup.
- return await runBackupCycleForProvider(ws, args);
+ return await runBackupCycleForProvider(wex, args);
}
// Some other response that we did not expect!
@@ -429,21 +424,22 @@ async function runBackupCycleForProvider(
}
export async function processBackupForProvider(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
backupProviderBaseUrl: string,
): Promise<TaskRunResult> {
- const provider = await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadOnly(async (tx) => {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
return await tx.backupProviders.get(backupProviderBaseUrl);
- });
+ },
+ );
if (!provider) {
throw Error("unknown backup provider");
}
logger.info(`running backup for provider ${backupProviderBaseUrl}`);
- return await runBackupCycleForProvider(ws, {
+ return await runBackupCycleForProvider(wex, {
backupProviderBaseUrl: provider.baseUrl,
});
}
@@ -459,14 +455,15 @@ export const codecForRemoveBackupProvider =
.build("RemoveBackupProviderRequest");
export async function removeBackupProvider(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: RemoveBackupProviderRequest,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
await tx.backupProviders.delete(req.provider);
- });
+ },
+ );
}
export interface RunBackupCycleRequest {
@@ -489,12 +486,12 @@ export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
* 3. Upload the updated backup blob.
*/
export async function runBackupCycle(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: RunBackupCycleRequest,
): Promise<void> {
- const providers = await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadOnly(async (tx) => {
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
if (req.providers) {
const rs = await Promise.all(
req.providers.map((id) => tx.backupProviders.get(id)),
@@ -502,35 +499,16 @@ export async function runBackupCycle(
return rs.filter(notEmpty);
}
return await tx.backupProviders.iter().toArray();
- });
+ },
+ );
for (const provider of providers) {
- await runBackupCycleForProvider(ws, {
+ await runBackupCycleForProvider(wex, {
backupProviderBaseUrl: provider.baseUrl,
});
}
}
-export interface SyncTermsOfServiceResponse {
- // maximum backup size supported
- storage_limit_in_megabytes: number;
-
- // Fee for an account, per year.
- annual_fee: AmountString;
-
- // protocol version supported by the server,
- // for now always "0.0".
- version: string;
-}
-
-export const codecForSyncTermsOfServiceResponse =
- (): Codec<SyncTermsOfServiceResponse> =>
- buildCodecForObject<SyncTermsOfServiceResponse>()
- .property("storage_limit_in_megabytes", codecForNumber())
- .property("annual_fee", codecForAmountString())
- .property("version", codecForString())
- .build("SyncTermsOfServiceResponse");
-
export interface AddBackupProviderRequest {
backupProviderBaseUrl: string;
@@ -586,15 +564,15 @@ export const codecForAddBackupProviderResponse =
.build("AddBackupProviderResponse");
export async function addBackupProvider(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: AddBackupProviderRequest,
): Promise<AddBackupProviderResponse> {
logger.info(`adding backup provider ${j2s(req)}`);
- await provideBackupState(ws);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
+ await provideBackupState(wex);
+ const canonUrl = req.backupProviderBaseUrl;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const oldProv = await tx.backupProviders.get(canonUrl);
if (oldProv) {
logger.info("old backup provider found");
@@ -610,16 +588,17 @@ export async function addBackupProvider(
}
return;
}
- });
+ },
+ );
const termsUrl = new URL("config", canonUrl);
- const resp = await ws.http.fetch(termsUrl.href);
+ const resp = await wex.http.fetch(termsUrl.href);
const terms = await readSuccessResponseJsonOrThrow(
resp,
codecForSyncTermsOfServiceResponse(),
);
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
let state: BackupProviderState;
//FIXME: what is the difference provisional and ready?
if (req.activate) {
@@ -647,196 +626,113 @@ export async function addBackupProvider(
baseUrl: canonUrl,
uids: [encodeCrock(getRandomBytes(32))],
});
- });
+ },
+ );
- return await runFirstBackupCycleForProvider(ws, {
+ return await runFirstBackupCycleForProvider(wex, {
backupProviderBaseUrl: canonUrl,
});
}
async function runFirstBackupCycleForProvider(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
args: BackupForProviderArgs,
): Promise<AddBackupProviderResponse> {
- const resp = await runBackupCycleForProvider(ws, args);
- switch (resp.type) {
- case TaskRunResultType.Error:
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- resp.errorDetail as any, //FIXME create an error for backup problems
- );
- case TaskRunResultType.Finished:
- return {
- status: "ok",
- };
- case TaskRunResultType.Longpoll:
- throw Error(
- "unexpected runFirstBackupCycleForProvider result (longpoll)",
- );
- case TaskRunResultType.Pending:
- return {
- status: "payment-required",
- talerUri: "FIXME",
- //talerUri: resp.result.talerUri,
- };
- default:
- assertUnreachable(resp);
- }
+ throw Error("not implemented");
+ // const resp = await runBackupCycleForProvider(ws, args);
+ // switch (resp.type) {
+ // case TaskRunResultType.Error:
+ // throw TalerError.fromDetail(
+ // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ // resp.errorDetail as any, //FIXME create an error for backup problems
+ // );
+ // case TaskRunResultType.Finished:
+ // return {
+ // status: "ok",
+ // };
+ // case TaskRunResultType.Pending:
+ // return {
+ // status: "payment-required",
+ // talerUri: "FIXME",
+ // //talerUri: resp.result.talerUri,
+ // };
+ // default:
+ // assertUnreachable(resp);
+ // }
}
export async function restoreFromRecoverySecret(): Promise<void> {
return;
}
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
- active: boolean;
- syncProviderBaseUrl: string;
- name: string;
- terms?: BackupProviderTerms;
- /**
- * Last communication issue with the provider.
- */
- lastError?: TalerErrorDetail;
- lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp;
- lastAttemptedBackupTimestamp?: TalerPreciseTimestamp;
- paymentProposalIds: string[];
- backupProblem?: BackupProblem;
- paymentStatus: ProviderPaymentStatus;
-}
-
-export type BackupProblem =
- | BackupUnreadableProblem
- | BackupConflictingDeviceProblem;
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupConflictingDeviceProblem {
- type: "backup-conflicting-device";
- otherDeviceId: string;
- myDeviceId: string;
- backupTimestamp: AbsoluteTime;
-}
-
-export type ProviderPaymentStatus =
- | ProviderPaymentTermsChanged
- | ProviderPaymentPaid
- | ProviderPaymentInsufficientBalance
- | ProviderPaymentUnpaid
- | ProviderPaymentPending;
-
export interface BackupInfo {
walletRootPub: string;
deviceId: string;
providers: ProviderInfo[];
}
-export enum ProviderPaymentType {
- Unpaid = "unpaid",
- Pending = "pending",
- InsufficientBalance = "insufficient-balance",
- Paid = "paid",
- TermsChanged = "terms-changed",
-}
-
-export interface ProviderPaymentUnpaid {
- type: ProviderPaymentType.Unpaid;
-}
-
-export interface ProviderPaymentInsufficientBalance {
- type: ProviderPaymentType.InsufficientBalance;
- amount: AmountString;
-}
-
-export interface ProviderPaymentPending {
- type: ProviderPaymentType.Pending;
- talerUri?: string;
-}
-
-export interface ProviderPaymentPaid {
- type: ProviderPaymentType.Paid;
- paidUntil: AbsoluteTime;
-}
-
-export interface ProviderPaymentTermsChanged {
- type: ProviderPaymentType.TermsChanged;
- paidUntil: AbsoluteTime;
- oldTerms: BackupProviderTerms;
- newTerms: BackupProviderTerms;
-}
-
async function getProviderPaymentInfo(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
provider: BackupProviderRecord,
): Promise<ProviderPaymentStatus> {
- if (!provider.currentPaymentProposalId) {
- return {
- type: ProviderPaymentType.Unpaid,
- };
- }
- const status = await checkPaymentByProposalId(
- ws,
- provider.currentPaymentProposalId,
- ).catch(() => undefined);
-
- if (!status) {
- return {
- type: ProviderPaymentType.Unpaid,
- };
- }
-
- switch (status.status) {
- case PreparePayResultType.InsufficientBalance:
- return {
- type: ProviderPaymentType.InsufficientBalance,
- amount: status.amountRaw,
- };
- case PreparePayResultType.PaymentPossible:
- return {
- type: ProviderPaymentType.Pending,
- talerUri: status.talerUri,
- };
- case PreparePayResultType.AlreadyConfirmed:
- if (status.paid) {
- return {
- type: ProviderPaymentType.Paid,
- paidUntil: AbsoluteTime.addDuration(
- AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
- durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
- ),
- };
- } else {
- return {
- type: ProviderPaymentType.Pending,
- talerUri: status.talerUri,
- };
- }
- default:
- assertUnreachable(status);
- }
+ throw Error("not implemented");
+ // if (!provider.currentPaymentProposalId) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+ // const status = await checkPaymentByProposalId(
+ // ws,
+ // provider.currentPaymentProposalId,
+ // ).catch(() => undefined);
+
+ // if (!status) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+
+ // switch (status.status) {
+ // case PreparePayResultType.InsufficientBalance:
+ // return {
+ // type: ProviderPaymentType.InsufficientBalance,
+ // amount: status.amountRaw,
+ // };
+ // case PreparePayResultType.PaymentPossible:
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // case PreparePayResultType.AlreadyConfirmed:
+ // if (status.paid) {
+ // return {
+ // type: ProviderPaymentType.Paid,
+ // paidUntil: AbsoluteTime.addDuration(
+ // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
+ // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
+ // ),
+ // };
+ // } else {
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // }
+ // default:
+ // assertUnreachable(status);
+ // }
}
/**
* Get information about the current state of wallet backups.
*/
export async function getBackupInfo(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<BackupInfo> {
- const backupConfig = await provideBackupState(ws);
- const providerRecords = await ws.db
- .mktx((x) => [x.backupProviders, x.operationRetries])
- .runReadOnly(async (tx) => {
+ const backupConfig = await provideBackupState(wex);
+ const providerRecords = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders", "operationRetries"] },
+ async (tx) => {
return await tx.backupProviders.iter().mapAsync(async (bp) => {
const opId = TaskIdentifiers.forBackup(bp);
const retryRecord = await tx.operationRetries.get(opId);
@@ -845,7 +741,8 @@ export async function getBackupInfo(
retryRecord,
};
});
- });
+ },
+ );
const providers: ProviderInfo[] = [];
for (const x of providerRecords) {
providers.push({
@@ -859,7 +756,7 @@ export async function getBackupInfo(
x.provider.state.tag === BackupProviderStateTag.Retrying
? x.retryRecord?.lastError
: undefined,
- paymentStatus: await getProviderPaymentInfo(ws, x.provider),
+ paymentStatus: await getProviderPaymentInfo(wex, x.provider),
terms: x.provider.terms,
name: x.provider.name,
});
@@ -876,14 +773,15 @@ export async function getBackupInfo(
* private key.
*/
export async function getBackupRecovery(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<BackupRecovery> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadOnly(async (tx) => {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
return await tx.backupProviders.iter().toArray();
- });
+ },
+ );
return {
providers: providers
.filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
@@ -898,12 +796,12 @@ export async function getBackupRecovery(
}
async function backupRecoveryTheirs(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
br: BackupRecovery,
) {
- await ws.db
- .mktx((x) => [x.config, x.backupProviders])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders", "config"] },
+ async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
@@ -944,23 +842,28 @@ async function backupRecoveryTheirs(
prov.lastBackupHash = undefined;
await tx.backupProviders.put(prov);
}
- });
+ },
+ );
}
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
+async function backupRecoveryOurs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
throw Error("not implemented");
}
export async function loadBackupRecovery(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
br: RecoveryLoadRequest,
): Promise<void> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadOnly(async (tx) => {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
return await tx.backupProviders.iter().toArray();
- });
+ },
+ );
let strategy = br.strategy;
if (
br.recovery.walletRootPriv != bs.walletRootPriv &&
@@ -975,9 +878,9 @@ export async function loadBackupRecovery(
strategy = RecoveryMergeStrategy.Theirs;
}
if (strategy === RecoveryMergeStrategy.Theirs) {
- return backupRecoveryTheirs(ws, br.recovery);
+ return backupRecoveryTheirs(wex, br.recovery);
} else {
- return backupRecoveryOurs(ws, br.recovery);
+ return backupRecoveryOurs(wex, br.recovery);
}
}
@@ -1001,52 +904,51 @@ export async function decryptBackup(
}
export async function provideBackupState(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<WalletBackupConfState> {
- const bs: ConfigRecord | undefined = await ws.db
- .mktx((stores) => [stores.config])
- .runReadOnly(async (tx) => {
+ const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx(
+ { storeNames: ["config"] },
+ async (tx) => {
return await tx.config.get(ConfigRecordKey.WalletBackupState);
- });
+ },
+ );
if (bs) {
checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
return bs.value;
}
// We need to generate the key outside of the transaction
// due to how IndexedDB works.
- const k = await ws.cryptoApi.createEddsaKeypair({});
+ const k = await wex.cryptoApi.createEddsaKeypair({});
const d = getRandomBytes(5);
// FIXME: device ID should be configured when wallet is initialized
// and be based on hostname
const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await ws.db
- .mktx((x) => [x.config])
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (!backupStateEntry) {
- backupStateEntry = {
- key: ConfigRecordKey.WalletBackupState,
- value: {
- deviceId,
- walletRootPub: k.pub,
- walletRootPriv: k.priv,
- lastBackupPlainHash: undefined,
- },
- };
- await tx.config.put(backupStateEntry);
- }
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- return backupStateEntry.value;
- });
+ return await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (!backupStateEntry) {
+ backupStateEntry = {
+ key: ConfigRecordKey.WalletBackupState,
+ value: {
+ deviceId,
+ walletRootPub: k.pub,
+ walletRootPriv: k.priv,
+ lastBackupPlainHash: undefined,
+ },
+ };
+ await tx.config.put(backupStateEntry);
+ }
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ return backupStateEntry.value;
+ });
}
export async function getWalletBackupState(
ws: InternalWalletState,
- tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>,
+ tx: WalletDbReadOnlyTransaction<["config"]>,
): Promise<WalletBackupConfState> {
const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
checkDbInvariant(!!bs, "wallet backup state should be in DB");
@@ -1055,30 +957,28 @@ export async function getWalletBackupState(
}
export async function setWalletDeviceId(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
deviceId: string,
): Promise<void> {
- await provideBackupState(ws);
- await ws.db
- .mktx((x) => [x.config])
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (
- !backupStateEntry ||
- backupStateEntry.key !== ConfigRecordKey.WalletBackupState
- ) {
- return;
- }
- backupStateEntry.value.deviceId = deviceId;
- await tx.config.put(backupStateEntry);
- });
+ await provideBackupState(wex);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (
+ !backupStateEntry ||
+ backupStateEntry.key !== ConfigRecordKey.WalletBackupState
+ ) {
+ return;
+ }
+ backupStateEntry.value.deviceId = deviceId;
+ await tx.config.put(backupStateEntry);
+ });
}
export async function getWalletDeviceId(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<string> {
- const bs = await provideBackupState(ws);
+ const bs = await provideBackupState(wex);
return bs.deviceId;
}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts
index d02ead783..72f850b25 100644
--- a/packages/taler-wallet-core/src/operations/backup/state.ts
+++ b/packages/taler-wallet-core/src/backup/state.ts
@@ -13,5 +13,3 @@
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/>
*/
-
-
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
new file mode 100644
index 000000000..76e604324
--- /dev/null
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -0,0 +1,797 @@
+/*
+ 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/>
+ */
+
+/**
+ * Functions to compute the wallet's balance.
+ *
+ * There are multiple definition of the wallet's balance.
+ * We use the following terminology:
+ *
+ * - "available": Balance that is available
+ * for spending from transactions in their final state and
+ * expected to be available from pending refreshes.
+ *
+ * - "pending-incoming": Expected (positive!) delta
+ * to the available balance that we expect to have
+ * after pending operations reach the "done" state.
+ *
+ * - "pending-outgoing": Amount that is currently allocated
+ * to be spent, but the spend operation could still be aborted
+ * and part of the pending-outgoing amount could be recovered.
+ *
+ * - "material": Balance that the wallet believes it could spend *right now*,
+ * without waiting for any operations to complete.
+ * This balance type is important when showing "insufficient balance" error messages.
+ *
+ * - "age-acceptable": Subset of the material balance that can be spent
+ * with age restrictions applied.
+ *
+ * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
+ * merchant (restricted via min age, exchange, auditor, wire_method).
+ *
+ * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
+ * can accept via their supported wire methods.
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AmountJson,
+ AmountLike,
+ Amounts,
+ assertUnreachable,
+ BalanceFlag,
+ BalancesResponse,
+ checkDbInvariant,
+ GetBalanceDetailRequest,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ ScopeInfo,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js";
+import {
+ DepositOperationStatus,
+ ExchangeEntryDbRecordStatus,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ PeerPushDebitStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupStatus,
+} from "./db.js";
+import {
+ getExchangeScopeInfo,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("operations/balance.ts");
+
+interface WalletBalance {
+ scopeInfo: ScopeInfo;
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingOutgoing: AmountJson;
+ flagIncomingKyc: boolean;
+ flagIncomingAml: boolean;
+ flagIncomingConfirmation: boolean;
+ flagOutgoingKyc: boolean;
+}
+
+/**
+ * Compute the available amount that the wallet expects to get
+ * out of a refresh group.
+ */
+function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ let available = Amounts.zeroOfCurrency(r.currency);
+ if (r.timestampFinished) {
+ return available;
+ }
+ for (let i = 0; i < r.oldCoinPubs.length; i++) {
+ available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
+ }
+ return available;
+}
+
+function getBalanceKey(scopeInfo: ScopeInfo): string {
+ switch (scopeInfo.type) {
+ case ScopeType.Auditor:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Exchange:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Global:
+ return `${scopeInfo.type};${scopeInfo.currency}`;
+ }
+}
+
+class BalancesStore {
+ private exchangeScopeCache: Record<string, ScopeInfo> = {};
+ private balanceStore: Record<string, WalletBalance> = {};
+
+ constructor(
+ private wex: WalletExecutionContext,
+ private tx: WalletDbReadOnlyTransaction<
+ [
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+ ) {}
+
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ private async initBalance(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<WalletBalance> {
+ let scopeInfo: ScopeInfo | undefined =
+ this.exchangeScopeCache[exchangeBaseUrl];
+ if (!scopeInfo) {
+ scopeInfo = await getExchangeScopeInfo(
+ this.tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
+ }
+ const balanceKey = getBalanceKey(scopeInfo);
+ const b = this.balanceStore[balanceKey];
+ if (!b) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ this.balanceStore[balanceKey] = {
+ scopeInfo,
+ available: zero,
+ pendingIncoming: zero,
+ pendingOutgoing: zero,
+ flagIncomingAml: false,
+ flagIncomingConfirmation: false,
+ flagIncomingKyc: false,
+ flagOutgoingKyc: false,
+ };
+ }
+ return this.balanceStore[balanceKey];
+ }
+
+ async addZero(currency: string, exchangeBaseUrl: string): Promise<void> {
+ await this.initBalance(currency, exchangeBaseUrl);
+ }
+
+ async addAvailable(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.available = Amounts.add(b.available, amount).amount;
+ }
+
+ async addPendingIncoming(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount;
+ }
+
+ async addPendingOutgoing(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount;
+ }
+
+ async setFlagIncomingAml(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingAml = true;
+ }
+
+ async setFlagIncomingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingKyc = true;
+ }
+
+ async setFlagIncomingConfirmation(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingConfirmation = true;
+ }
+
+ async setFlagOutgoingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagOutgoingKyc = true;
+ }
+
+ toBalancesResponse(): BalancesResponse {
+ const balancesResponse: BalancesResponse = {
+ balances: [],
+ };
+
+ const balanceStore = this.balanceStore;
+
+ Object.keys(balanceStore)
+ .sort()
+ .forEach((c) => {
+ const v = balanceStore[c];
+ const flags: BalanceFlag[] = [];
+ if (v.flagIncomingAml) {
+ flags.push(BalanceFlag.IncomingAml);
+ }
+ if (v.flagIncomingKyc) {
+ flags.push(BalanceFlag.IncomingKyc);
+ }
+ if (v.flagIncomingConfirmation) {
+ flags.push(BalanceFlag.IncomingConfirmation);
+ }
+ if (v.flagOutgoingKyc) {
+ flags.push(BalanceFlag.OutgoingKyc);
+ }
+ balancesResponse.balances.push({
+ scopeInfo: v.scopeInfo,
+ available: Amounts.stringify(v.available),
+ pendingIncoming: Amounts.stringify(v.pendingIncoming),
+ pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
+ // FIXME: This field is basically not implemented, do we even need it?
+ hasPendingTransactions: false,
+ // FIXME: This field is basically not implemented, do we even need it?
+ requiresUserInput: false,
+ flags,
+ });
+ });
+ return balancesResponse;
+ }
+}
+
+/**
+ * Get balance information.
+ */
+export async function getBalancesInsideTransaction(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "coinAvailability",
+ "refreshGroups",
+ "depositGroups",
+ "withdrawalGroups",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "peerPushDebit",
+ ]
+ >,
+): Promise<BalancesResponse> {
+ const balanceStore: BalancesStore = new BalancesStore(wex, tx);
+
+ const keyRangeActive = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ await tx.exchanges.iter().forEachAsync(async (ex) => {
+ if (
+ ex.entryStatus === ExchangeEntryDbRecordStatus.Used ||
+ ex.tosAcceptedTimestamp != null
+ ) {
+ const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl);
+ if (det) {
+ await balanceStore.addZero(det.currency, ex.baseUrl);
+ }
+ }
+ });
+
+ await tx.coinAvailability.iter().forEachAsync(async (ca) => {
+ const count = ca.visibleCoinCount ?? 0;
+ await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl);
+ for (let i = 0; i < count; i++) {
+ await balanceStore.addAvailable(
+ ca.currency,
+ ca.exchangeBaseUrl,
+ ca.value,
+ );
+ }
+ });
+
+ await tx.refreshGroups.iter().forEachAsync(async (r) => {
+ switch (r.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ default:
+ return;
+ }
+ const perExchange = r.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ await balanceStore.addAvailable(r.currency, e, x.outputEffective);
+ }
+ });
+
+ await tx.withdrawalGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (wg) => {
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.DialogProposed:
+ case WithdrawalGroupStatus.Done:
+ // Does not count as pendingIncoming
+ return;
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ // Pending, but no special flag.
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.PendingKyc: {
+ checkDbInvariant(
+ wg.denomsSel !== undefined,
+ "wg in kyc state should have been initialized",
+ );
+ const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
+ await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl);
+ break;
+ }
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.SuspendedAml: {
+ checkDbInvariant(
+ wg.denomsSel !== undefined,
+ "wg in aml state should have been initialized",
+ );
+ const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
+ await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl);
+ break;
+ }
+ case WithdrawalGroupStatus.PendingRegisteringBank: {
+ if (wg.denomsSel && wg.exchangeBaseUrl) {
+ const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
+ await balanceStore.setFlagIncomingConfirmation(
+ currency,
+ wg.exchangeBaseUrl,
+ );
+ }
+ break;
+ }
+ case WithdrawalGroupStatus.PendingWaitConfirmBank: {
+ checkDbInvariant(
+ wg.denomsSel !== undefined,
+ "wg in confirmed state should have been initialized",
+ );
+ const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
+ await balanceStore.setFlagIncomingConfirmation(
+ currency,
+ wg.exchangeBaseUrl,
+ );
+ break;
+ }
+ default:
+ assertUnreachable(wg.status);
+ }
+ if (wg.denomsSel && wg.exchangeBaseUrl) {
+ // only inform pending incoming if amount and exchange has been selected
+ const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
+ await balanceStore.addPendingIncoming(
+ currency,
+ wg.exchangeBaseUrl,
+ wg.denomsSel.totalCoinValue,
+ );
+ }
+ });
+
+ await tx.peerPushDebit.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (ppdRecord) => {
+ switch (ppdRecord.status) {
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const currency = Amounts.currencyOf(ppdRecord.amount);
+ await balanceStore.addPendingOutgoing(
+ currency,
+ ppdRecord.exchangeBaseUrl,
+ ppdRecord.totalCost,
+ );
+ break;
+ }
+ }
+ });
+
+ await tx.depositGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (dgRecord) => {
+ const perExchange = dgRecord.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ const currency = Amounts.currencyOf(dgRecord.amount);
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ await balanceStore.setFlagOutgoingKyc(currency, e);
+ }
+
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.SuspendedDeposit:
+ case DepositOperationStatus.SuspendedTrack:
+ case DepositOperationStatus.PendingDeposit: {
+ const perExchange = dgRecord.infoPerExchange;
+ if (perExchange) {
+ for (const [e, v] of Object.entries(perExchange)) {
+ await balanceStore.addPendingOutgoing(
+ currency,
+ e,
+ v.amountEffective,
+ );
+ }
+ }
+ }
+ }
+ }
+ });
+
+ return balanceStore.toBalancesResponse();
+}
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ wex: WalletExecutionContext,
+): Promise<BalancesResponse> {
+ logger.trace("starting to compute balance");
+
+ const wbal = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "coins",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "purchases",
+ "refreshGroups",
+ "withdrawalGroups",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ return getBalancesInsideTransaction(wex, tx);
+ },
+ );
+
+ logger.trace("finished computing wallet balance");
+
+ return wbal;
+}
+
+export interface PaymentRestrictionsForBalance {
+ currency: string;
+ minAge: number;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethods: string[] | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export interface AcceptableExchanges {
+ /**
+ * Exchanges accepted by the merchant, but wire method might not match.
+ */
+ acceptableExchanges: string[];
+
+ /**
+ * Exchanges accepted by the merchant, including a matching
+ * wire method, i.e. the merchant can deposit coins there.
+ */
+ depositableExchanges: string[];
+}
+
+export interface PaymentBalanceDetails {
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountJson;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountJson;
+
+ /**
+ * Balance of type "age-acceptable" (see balance.ts for definition).
+ */
+ balanceAgeAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ */
+ balanceReceiverAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-depositable" (see balance.ts for definition).
+ */
+ balanceReceiverDepositable: AmountJson;
+
+ /**
+ * Balance that's depositable with the exchange.
+ * This balance is reduced by the exchange's debit restrictions
+ * and wire fee configuration.
+ */
+ balanceExchangeDepositable: AmountJson;
+
+ maxEffectiveSpendAmount: AmountJson;
+}
+
+export async function getPaymentBalanceDetails(
+ wex: WalletExecutionContext,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ return getPaymentBalanceDetailsInTx(wex, tx, req);
+ },
+ );
+}
+
+export async function getPaymentBalanceDetailsInTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ]
+ >,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ const d: PaymentBalanceDetails = {
+ balanceAvailable: Amounts.zeroOfCurrency(req.currency),
+ balanceMaterial: Amounts.zeroOfCurrency(req.currency),
+ balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency),
+ maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency),
+ balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency),
+ };
+
+ logger.info(`computing balance details for ${j2s(req)}`);
+
+ const availableCoins = await tx.coinAvailability.getAll();
+
+ for (const ca of availableCoins) {
+ if (ca.currency != req.currency) {
+ continue;
+ }
+
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ ca.exchangeBaseUrl,
+ ca.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+
+ const wireDetails = await getExchangeWireDetailsInTx(
+ tx,
+ ca.exchangeBaseUrl,
+ );
+ if (!wireDetails) {
+ continue;
+ }
+
+ const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
+ const coinAmount: AmountJson = Amounts.mult(
+ singleCoinAmount,
+ ca.freshCoinCount,
+ ).amount;
+
+ let wireOkay = false;
+ if (req.restrictWireMethods == null) {
+ wireOkay = true;
+ } else {
+ for (const wm of req.restrictWireMethods) {
+ const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails);
+ if (wmf) {
+ wireOkay = true;
+ break;
+ }
+ }
+ }
+
+ if (wireOkay) {
+ d.balanceExchangeDepositable = Amounts.add(
+ d.balanceExchangeDepositable,
+ coinAmount,
+ ).amount;
+ }
+
+ let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge;
+
+ let merchantExchangeAcceptable = false;
+
+ if (!req.restrictExchanges) {
+ merchantExchangeAcceptable = true;
+ } else {
+ for (const ex of req.restrictExchanges.exchanges) {
+ if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ for (const acceptedAuditor of req.restrictExchanges.auditors) {
+ for (const exchangeAuditor of wireDetails.auditors) {
+ if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ }
+ }
+
+ const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay;
+
+ d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
+ d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
+ if (ageOkay) {
+ d.balanceAgeAcceptable = Amounts.add(
+ d.balanceAgeAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeAcceptable) {
+ d.balanceReceiverAcceptable = Amounts.add(
+ d.balanceReceiverAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeDepositable) {
+ d.balanceReceiverDepositable = Amounts.add(
+ d.balanceReceiverDepositable,
+ coinAmount,
+ ).amount;
+ }
+ }
+ }
+
+ if (
+ ageOkay &&
+ wireOkay &&
+ merchantExchangeAcceptable &&
+ merchantExchangeDepositable
+ ) {
+ d.maxEffectiveSpendAmount = Amounts.add(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(ca.value, ca.freshCoinCount).amount,
+ ).amount;
+
+ d.maxEffectiveSpendAmount = Amounts.sub(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount,
+ ).amount;
+ }
+ }
+
+ await tx.refreshGroups.iter().forEach((r) => {
+ if (r.currency != req.currency) {
+ return;
+ }
+ d.balanceAvailable = Amounts.add(
+ d.balanceAvailable,
+ computeRefreshGroupAvailableAmount(r),
+ ).amount;
+ });
+
+ return d;
+}
+
+export async function getBalanceDetail(
+ wex: WalletExecutionContext,
+ req: GetBalanceDetailRequest,
+): Promise<PaymentBalanceDetails> {
+ const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
+ const wires = new Array<string>();
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || req.currency !== details.currency) {
+ continue;
+ }
+ details.wireInfo.accounts.forEach((a) => {
+ const payto = parsePaytoUri(a.payto_uri);
+ if (payto && !wires.includes(payto.targetType)) {
+ wires.push(payto.targetType);
+ }
+ });
+ exchanges.push({
+ exchangePub: details.masterPublicKey,
+ exchangeBaseUrl: e.baseUrl,
+ });
+ }
+ },
+ );
+
+ return await getPaymentBalanceDetails(wex, {
+ currency: req.currency,
+ restrictExchanges: {
+ auditors: [],
+ exchanges,
+ },
+ restrictWireMethods: wires,
+ minAge: 0,
+ depositPaytoUri: undefined,
+ });
+}
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
index 0715c999f..c7cb2857e 100644
--- a/packages/taler-wallet-core/src/util/coinSelection.test.ts
+++ b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -19,13 +19,13 @@ import {
Amounts,
DenomKeyType,
Duration,
- TalerProtocolTimestamp,
j2s,
} from "@gnu-taler/taler-util";
import test from "ava";
import {
AvailableDenom,
- testing_greedySelectPeer,
+ CoinSelectionTally,
+ emptyTallyForPeerPayment,
testing_selectGreedy,
} from "./coinSelection.js";
@@ -42,12 +42,12 @@ const inThePast = AbsoluteTime.toProtocolTimestamp(
test("p2p: should select the coin", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
- const tally = {
- amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency),
- };
- const coins = testing_greedySelectPeer(
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ t.log(`tally before: ${j2s(tally)}`);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
createCandidates([
{
amount: "LOCAL:10" as AmountString,
@@ -56,11 +56,11 @@ test("p2p: should select the coin", (t) => {
fromExchange: "http://exchange.localhost/",
},
]),
- instructedAmount,
tally,
);
- t.log(j2s(coins));
+ t.log(`coins: ${j2s(coins)}`);
+ t.log(`tally: ${j2s(tally)}`);
t.assert(coins != null);
@@ -70,26 +70,17 @@ test("p2p: should select the coin", (t) => {
denomPubHash: "hash0",
maxAge: 32,
contributions: [Amounts.parseOrThrow("LOCAL:2.1")],
- expireDeposit: inTheDistantFuture,
- expireWithdraw: inTheDistantFuture,
},
});
-
- t.deepEqual(tally, {
- amountAcc: Amounts.parseOrThrow("LOCAL:2"),
- depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.1"),
- lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"),
- });
});
test("p2p: should select 3 coins", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:20");
- const tally = {
- amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency),
- };
- const coins = testing_greedySelectPeer(
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
createCandidates([
{
amount: "LOCAL:10" as AmountString,
@@ -98,7 +89,6 @@ test("p2p: should select 3 coins", (t) => {
fromExchange: "http://exchange.localhost/",
},
]),
- instructedAmount,
tally,
);
@@ -108,30 +98,21 @@ test("p2p: should select 3 coins", (t) => {
denomPubHash: "hash0",
maxAge: 32,
contributions: [
- Amounts.parseOrThrow("LOCAL:9.9"),
- Amounts.parseOrThrow("LOCAL:9.9"),
- Amounts.parseOrThrow("LOCAL:0.5"),
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:0.3"),
],
- expireDeposit: inTheDistantFuture,
- expireWithdraw: inTheDistantFuture,
},
});
-
- t.deepEqual(tally, {
- amountAcc: Amounts.parseOrThrow("LOCAL:20"),
- depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.3"),
- lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"),
- });
});
test("p2p: can't select since the instructed amount is too high", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:60");
- const tally = {
- amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency),
- lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency),
- };
- const coins = testing_greedySelectPeer(
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
createCandidates([
{
amount: "LOCAL:10" as AmountString,
@@ -140,17 +121,10 @@ test("p2p: can't select since the instructed amount is too high", (t) => {
fromExchange: "http://exchange.localhost/",
},
]),
- instructedAmount,
tally,
);
t.is(coins, undefined);
-
- t.deepEqual(tally, {
- amountAcc: Amounts.parseOrThrow("LOCAL:49"),
- depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.5"),
- lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"),
- });
});
test("pay: select one coin to pay with fee", (t) => {
@@ -159,28 +133,15 @@ test("pay: select one coin to pay with fee", (t) => {
const zero = Amounts.zeroOfCurrency(payment.currency);
const tally = {
amountPayRemaining: payment,
- amountWireFeeLimitRemaining: zero,
amountDepositFeeLimitRemaining: zero,
customerDepositFees: zero,
customerWireFees: zero,
wireFeeCoveredForExchange: new Set<string>(),
lastDepositFee: zero,
- };
+ } satisfies CoinSelectionTally;
const coins = testing_selectGreedy(
{
- auditors: [],
- exchanges: [
- {
- exchangeBaseUrl: "http://exchange.localhost/",
- exchangePub: "E5M8CGRDHXF1RCVP3B8TQCTDYNQ7T4XHWR5SVEQRGVVMVME41VJ0",
- },
- ],
- contractTermsAmount: payment,
- depositFeeLimit: zero,
- wireFeeAmortization: 1,
- wireFeeLimit: zero,
- prevPayCoins: [],
- wireMethod: "x-taler-bank",
+ wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee },
},
createCandidates([
{
@@ -190,7 +151,6 @@ test("pay: select one coin to pay with fee", (t) => {
fromExchange: "http://exchange.localhost/",
},
]),
- { "http://exchange.localhost/": exchangeWireFee },
tally,
);
@@ -200,19 +160,16 @@ test("pay: select one coin to pay with fee", (t) => {
denomPubHash: "hash0",
maxAge: 32,
contributions: [Amounts.parseOrThrow("LOCAL:2.2")],
- expireDeposit: inTheDistantFuture,
- expireWithdraw: inTheDistantFuture,
},
});
t.deepEqual(tally, {
- amountPayRemaining: Amounts.parseOrThrow("LOCAL:2"),
- amountWireFeeLimitRemaining: zero,
+ amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"),
amountDepositFeeLimitRemaining: zero,
- customerDepositFees: zero,
- customerWireFees: zero,
- wireFeeCoveredForExchange: new Set(),
- lastDepositFee: zero,
+ customerDepositFees: Amounts.parse("LOCAL:0.1"),
+ customerWireFees: Amounts.parse("LOCAL:0.1"),
+ wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]),
+ lastDepositFee: Amounts.parse("LOCAL:0.1"),
});
});
@@ -247,3 +204,78 @@ function createCandidates(
};
});
}
+
+test("p2p: regression STATER", (t) => {
+ const candidates = [
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000WTR9ERP6FYDM4581C1WY4DX6EA6ZP0RKDEY1VCEG1HGZQDB1E1MT0HSPWKVWYY8GN99YG8JV2BQHCV608V3AP00HZ44M4R2RDK3MEG1HY3H5VP2YESFDXC8C2J0BT6E662JJYN4MCFR8Q8ZFD7ZCA8HGBNVG4JMTS5MBDTF9CX3JC25H702K1FG2C54HR48767D18F2H11HMVK7EEF51QRGE08T704VRCNZ6WTM3Z73Z5DW4W26GBEWTDZZ4HX94HRJEH8YENXAW5T5E39TQQN7MZ7HEPB59BQWB0DDMM8MAE274BV3HC2AJVCSXFJSKBAK1B9HKERPWF7Z5556VJG6YJ9236G5SFM3RC22PJM2SXHYBWFV1WBAYF1F2026C0CM5Q3RPQETHCWZTEX8KJ2J1K904002",
+ },
+ denomPubHash:
+ "TF5S4VJ8P3NN0SM5R1KW5MP665KEFMGAT2RPR70BMG0WQ5A72J53GDDE0YSCTWEXHRW8FMMX3X27RQK4D1VH69GVJBYR5RSJY3X5FS8",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:2",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 6,
+ maxAge: 32,
+ },
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000Y84BTTQCZ28AS2KZ867V05WES3YPN34X51DNF14ADGW2HNG9YFXCCNVQ2JA9ZT3KSBD17ZN9Y71KGWAWEFYMHE0S61DW63WN58VWRXQ92440V1JSZDD7FDTYEVNGG8ZVARVZ4GGF1RCDM93R28M067S5CPRZFCCQBRFFM9YDK2W06WDXE96BDCB8MZEYPHSGK5CTDY6XJE18EMRWYRBAG0H8P6QGQS73REXX66PTJ3MRX3AK3ARZF8417QKMZZPNS1JV5EYPAC7X8R1F9G1GWAQXVVQ2XTA5NMVMNJDJ0KEM93AXD4W2C7XMVJFSQN8RVB9KZ8JXWGN1YJQK7P6476HV896THKQ05QK4F0C65P4HA7QDX84C91F42PZVMH8AMYMA2NBXEYXS0EV8NXZHMZ30JF04002",
+ },
+ denomPubHash:
+ "WCMKBGR8ZKJ62YZXCRNT3EHPFQQ2M0B5CGZXW0PYA76G8PPXJMXZ7Q3WBP2DA3Z4BF21K3X9AG769RYCC39C3PT0R1DCTJA2PRTSHSR",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:1",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 1,
+ maxAge: 32,
+ },
+ ];
+ const instructedAmount = Amounts.parseOrThrow("STATER:1");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const res = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates as any,
+ tally,
+ );
+ t.assert(!!res);
+});
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
new file mode 100644
index 000000000..a60e41ecd
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -0,0 +1,1258 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Selection of coins for payments.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AccountRestriction,
+ AgeRestriction,
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ AmountJson,
+ Amounts,
+ checkDbInvariant,
+ checkLogicInvariant,
+ CoinStatus,
+ DenominationInfo,
+ ExchangeGlobalFees,
+ ForcedCoinSel,
+ InternationalizedString,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ PayCoinSelection,
+ PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
+ SelectedCoin,
+ SelectedProspectiveCoin,
+ strcmp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { getPaymentBalanceDetailsInTx } from "./balance.js";
+import { getAutoRefreshExecuteThreshold } from "./common.js";
+import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("coinSelection.ts");
+
+export type PreviousPayCoins = {
+ coinPub: string;
+ contribution: AmountJson;
+}[];
+
+export interface ExchangeRestrictionSpec {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+}
+
+export interface CoinSelectionTally {
+ /**
+ * Amount that still needs to be paid.
+ * May increase during the computation when fees need to be covered.
+ */
+ amountPayRemaining: AmountJson;
+
+ /**
+ * Allowance given by the merchant towards deposit fees
+ * (and wire fees after wire fee limit is exhausted)
+ */
+ amountDepositFeeLimitRemaining: AmountJson;
+
+ customerDepositFees: AmountJson;
+
+ customerWireFees: AmountJson;
+
+ wireFeeCoveredForExchange: Set<string>;
+
+ lastDepositFee: AmountJson;
+}
+
+/**
+ * Account for the fees of spending a coin.
+ */
+function tallyFees(
+ tally: CoinSelectionTally,
+ wireFeesPerExchange: Record<string, AmountJson>,
+ exchangeBaseUrl: string,
+ feeDeposit: AmountJson,
+): void {
+ const currency = tally.amountPayRemaining.currency;
+
+ if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
+ const wf =
+ wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
+ // The remaining, amortized amount needs to be paid by the
+ // wallet or covered by the deposit fee allowance.
+ let wfRemaining = wf;
+ // This is the amount forgiven via the deposit fee allowance.
+ const wfDepositForgiven = Amounts.min(
+ tally.amountDepositFeeLimitRemaining,
+ wfRemaining,
+ );
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ wfDepositForgiven,
+ ).amount;
+ wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
+ tally.customerWireFees = Amounts.add(
+ tally.customerWireFees,
+ wfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ wfRemaining,
+ ).amount;
+ tally.wireFeeCoveredForExchange.add(exchangeBaseUrl);
+ }
+
+ const dfForgiven = Amounts.min(
+ feeDeposit,
+ tally.amountDepositFeeLimitRemaining,
+ );
+
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ dfForgiven,
+ ).amount;
+
+ // How much does the user spend on deposit fees for this coin?
+ const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ dfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ dfRemaining,
+ ).amount;
+ tally.lastDepositFee = feeDeposit;
+}
+
+export type SelectPayCoinsResult =
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ }
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
+ | { type: "success"; coinSel: PayCoinSelection };
+
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ );
+
+ let selectedDenom: SelResult | undefined;
+ if (req.forcedSelection) {
+ selectedDenom = selectForced(req, candidateDenoms);
+ } else {
+ // FIXME: Here, we should select coins in a smarter way.
+ // Instead of always spending the next-largest coin,
+ // we should try to find the smallest coin that covers the
+ // amount.
+ selectedDenom = selectGreedy(
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
+/**
+ * Select coins to spend under the merchant's constraints.
+ *
+ * The prevPayCoins can be specified to "repair" a coin selection
+ * by adding additional coins, after a broken (e.g. double-spent) coin
+ * has been removed from the selection.
+ */
+export async function selectPayCoins(
+ wex: WalletExecutionContext,
+ req: SelectPayCoinRequestNg,
+): Promise<SelectPayCoinsResult> {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selecting coins for ${j2s(req)}`);
+ }
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
+
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
+ );
+
+ if (prospectiveAvSel) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvSel.sel)) {
+ const mySel = prospectiveAvSel.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ customerDepositFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerDepositFees,
+ ),
+ customerWireFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerWireFees,
+ ),
+ },
+ } satisfies SelectPayCoinsResult;
+ }
+
+ return {
+ type: "failure",
+ insufficientBalanceDetails: await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ requiredMinimumAge: req.requiredMinimumAge,
+ wireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ },
+ ),
+ } satisfies SelectPayCoinsResult;
+ }
+
+ const coinSel = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`coin selection: ${j2s(coinSel)}`);
+ }
+
+ return {
+ type: "success",
+ coinSel,
+ };
+ },
+ );
+}
+
+async function maybeRepairCoinSelection(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ prevPayCoins: PreviousPayCoins,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+ feeInfo: {
+ wireFeesPerExchange: Record<string, AmountJson>;
+ },
+): Promise<void> {
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ const coin = await tx.coins.get(prev.coinPub);
+ if (!coin) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ tallyFees(
+ tally,
+ feeInfo.wireFeesPerExchange,
+ coin.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
+
+ coinRes.push({
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denomPubHash: coin.denomPubHash,
+ coinPub: prev.coinPub,
+ contribution: Amounts.stringify(prev.contribution),
+ });
+ }
+}
+
+/**
+ * Returns undefined if the success response could not be assembled,
+ * as not enough coins are actually available.
+ */
+async function assembleSelectPayCoinsSuccessResult(
+ tx: WalletDbReadOnlyTransaction<["coins"]>,
+ finalSel: SelResult,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+): Promise<PayCoinSelection> {
+ for (const dph of Object.keys(finalSel)) {
+ const selInfo = finalSel[dph];
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.trace(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
+ }
+
+ for (let i = 0; i < selInfo.contributions.length; i++) {
+ coinRes.push({
+ denomPubHash: coins[i].denomPubHash,
+ coinPub: coins[i].coinPub,
+ contribution: Amounts.stringify(selInfo.contributions[i]),
+ exchangeBaseUrl: coins[i].exchangeBaseUrl,
+ });
+ }
+ }
+
+ return {
+ coins: coinRes,
+ customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+ customerWireFees: Amounts.stringify(tally.customerWireFees),
+ };
+}
+
+interface ReportInsufficientBalanceRequest {
+ instructedAmount: AmountJson;
+ requiredMinimumAge: number | undefined;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ wireMethod: string | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export async function reportInsufficientBalanceDetails(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "exchanges",
+ "exchangeDetails",
+ "refreshGroups",
+ "denominations",
+ ]
+ >,
+ req: ReportInsufficientBalanceRequest,
+): Promise<PaymentInsufficientBalanceDetails> {
+ const details = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
+ const exchanges = await tx.exchanges.getAll();
+
+ for (const exch of exchanges) {
+ if (!exch.detailsPointer) {
+ continue;
+ }
+ let missingGlobalFees = false;
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ missingGlobalFees = true;
+ } else {
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ missingGlobalFees = true;
+ }
+ }
+ const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: {
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.baseUrl,
+ exchangePub: exch.detailsPointer?.masterPublicKey,
+ },
+ ],
+ auditors: [],
+ },
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(exchDet.balanceAvailable),
+ balanceMaterial: Amounts.stringify(exchDet.balanceMaterial),
+ balanceExchangeDepositable: Amounts.stringify(
+ exchDet.balanceExchangeDepositable,
+ ),
+ balanceAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable),
+ balanceReceiverAcceptable: Amounts.stringify(
+ exchDet.balanceReceiverAcceptable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ exchDet.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ exchDet.maxEffectiveSpendAmount,
+ ),
+ missingGlobalFees,
+ };
+ }
+
+ return {
+ amountRequested: Amounts.stringify(req.instructedAmount),
+ balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+ balanceAvailable: Amounts.stringify(details.balanceAvailable),
+ balanceMaterial: Amounts.stringify(details.balanceMaterial),
+ balanceReceiverAcceptable: Amounts.stringify(
+ details.balanceReceiverAcceptable,
+ ),
+ balanceExchangeDepositable: Amounts.stringify(
+ details.balanceExchangeDepositable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ details.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ perExchange,
+ };
+}
+
+function makeAvailabilityKey(
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+ maxAge: number,
+): string {
+ return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
+}
+
+/**
+ * Selection result.
+ */
+interface SelResult {
+ /**
+ * Map from an availability key
+ * to an array of contributions.
+ */
+ [avKey: string]: {
+ exchangeBaseUrl: string;
+ denomPubHash: string;
+ maxAge: number;
+ contributions: AmountJson[];
+ };
+}
+
+export function testing_selectGreedy(
+ ...args: Parameters<typeof selectGreedy>
+): ReturnType<typeof selectGreedy> {
+ return selectGreedy(...args);
+}
+
+export interface SelectGreedyRequest {
+ wireFeesPerExchange: Record<string, AmountJson>;
+}
+
+function selectGreedy(
+ req: SelectGreedyRequest,
+ candidateDenoms: AvailableDenom[],
+ tally: CoinSelectionTally,
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+ for (const denom of candidateDenoms) {
+ const contributions: AmountJson[] = [];
+
+ // Don't use this coin if depositing it is more expensive than
+ // the amount it would give the merchant.
+ if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
+ tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
+ continue;
+ }
+
+ for (
+ let i = 0;
+ i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
+ i++
+ ) {
+ tallyFees(
+ tally,
+ req.wireFeesPerExchange,
+ denom.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+
+ const coinSpend = Amounts.max(
+ Amounts.min(tally.amountPayRemaining, denom.value),
+ denom.feeDeposit,
+ );
+
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ coinSpend,
+ ).amount;
+
+ contributions.push(coinSpend);
+ }
+
+ if (contributions.length) {
+ const avKey = makeAvailabilityKey(
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ denom.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ maxAge: denom.maxAge,
+ };
+ }
+ sd.contributions.push(...contributions);
+ selectedDenom[avKey] = sd;
+ }
+ }
+ return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
+}
+
+function selectForced(
+ req: SelectPayCoinRequestNg,
+ candidateDenoms: AvailableDenom[],
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+
+ const forcedSelection = req.forcedSelection;
+ checkLogicInvariant(!!forcedSelection);
+
+ for (const forcedCoin of forcedSelection.coins) {
+ let found = false;
+ for (const aci of candidateDenoms) {
+ if (aci.numAvailable <= 0) {
+ continue;
+ }
+ if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
+ aci.numAvailable--;
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
+ selectedDenom[avKey] = sd;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ throw Error("can't find coin for forced coin selection");
+ }
+ }
+ return selectedDenom;
+}
+
+export function checkAccountRestriction(
+ paytoUri: string,
+ restrictions: AccountRestriction[],
+): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
+ for (const myRestriction of restrictions) {
+ switch (myRestriction.type) {
+ case "deny":
+ return { ok: false };
+ case "regex":
+ const regex = new RegExp(myRestriction.payto_regex);
+ if (!regex.test(paytoUri)) {
+ return {
+ ok: false,
+ hint: myRestriction.human_hint,
+ hintI18n: myRestriction.human_hint_i18n,
+ };
+ }
+ }
+ }
+ return {
+ ok: true,
+ };
+}
+
+export interface SelectPayCoinRequestNg {
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethod: string;
+ contractTermsAmount: AmountJson;
+ depositFeeLimit: AmountJson;
+ prevPayCoins?: PreviousPayCoins;
+ requiredMinimumAge?: number;
+ forcedSelection?: ForcedCoinSel;
+
+ /**
+ * Deposit payto URI, in case we already know the account that
+ * will be deposited into.
+ *
+ * That is typically the case when the wallet does a deposit to
+ * return funds to the user's own bank account.
+ */
+ depositPaytoUri?: string;
+}
+
+export type AvailableDenom = DenominationInfo & {
+ maxAge: number;
+ numAvailable: number;
+};
+
+export function findMatchingWire(
+ wireMethod: string,
+ depositPaytoUri: string | undefined,
+ exchangeWireDetails: ExchangeWireDetails,
+): { wireFee: AmountJson } | undefined {
+ for (const acc of exchangeWireDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType !== wireMethod) {
+ continue;
+ }
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
+ wireMethod
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (!wireFeeStr) {
+ continue;
+ }
+
+ let debitAccountCheckOk = false;
+ if (depositPaytoUri) {
+ // FIXME: We should somehow propagate the hint here!
+ const checkResult = checkAccountRestriction(
+ depositPaytoUri,
+ acc.debit_restrictions,
+ );
+ if (checkResult.ok) {
+ debitAccountCheckOk = true;
+ }
+ } else {
+ debitAccountCheckOk = true;
+ }
+
+ if (!debitAccountCheckOk) {
+ continue;
+ }
+
+ return {
+ wireFee: Amounts.parseOrThrow(wireFeeStr),
+ };
+ }
+ return undefined;
+}
+
+function checkExchangeAccepted(
+ exchangeDetails: ExchangeWireDetails,
+ exchangeRestrictions: ExchangeRestrictionSpec | undefined,
+): boolean {
+ if (!exchangeRestrictions) {
+ return true;
+ }
+ let accepted = false;
+ for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of exchangeRestrictions.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ return accepted;
+}
+
+interface SelectPayCandidatesRequest {
+ instructedAmount: AmountJson;
+ restrictWireMethod: string | undefined;
+ depositPaytoUri?: string;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
+}
+
+async function selectPayCandidates(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
+ >,
+ req: SelectPayCandidatesRequest,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+ // FIXME: Use the existing helper (from balance.ts) to
+ // get acceptable exchanges.
+ logger.shouldLogTrace() &&
+ logger.trace(`selecting available coin candidates for ${j2s(req)}`);
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ );
+ // 1. exchange has same currency
+ if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ logger.shouldLogTrace() &&
+ logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
+ continue;
+ }
+
+ // 2. Exchange supports wire method (only for pay/deposit)
+ if (req.restrictWireMethod) {
+ const wire = findMatchingWire(
+ req.restrictWireMethod,
+ req.depositPaytoUri,
+ exchangeDetails,
+ );
+ if (!wire) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `skipping ${exchange.baseUrl} due to missing wire info mismatch`,
+ );
+ }
+ continue;
+ }
+ wfPerExchange[exchange.baseUrl] = wire.wireFee;
+ }
+
+ // 3. exchange is trusted in the exchange list or auditor list
+ let accepted = checkExchangeAccepted(
+ exchangeDetails,
+ req.restrictExchanges,
+ );
+ if (!accepted) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`);
+ }
+ continue;
+ }
+
+ // 4. filter coins restricted by age
+ let ageLower = 0;
+ let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+ if (req.requiredMinimumAge) {
+ ageLower = req.requiredMinimumAge;
+ }
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
+ ),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${myExchangeCoins.length} candidate records`,
+ );
+ }
+
+ let numUsable = 0;
+
+ // 5. save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked) {
+ logger.trace("denom is revoked");
+ continue;
+ }
+ if (!denom.isOffered) {
+ logger.trace("denom is unoffered");
+ continue;
+ }
+ numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable,
+ maxAge: coinAvail.maxAge,
+ });
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${numUsable} candidate records with usable denominations`,
+ );
+ }
+ }
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ denoms.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
+ );
+ return [denoms, wfPerExchange];
+}
+
+export interface PeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ /**
+ * Info of Coins that were selected.
+ */
+ coins: SelectedCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export type SelectPeerCoinsResult =
+ | { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+
+export interface PeerCoinSelectionRequest {
+ instructedAmount: AmountJson;
+
+ /**
+ * Instruct the coin selection to repair this coin
+ * selection instead of selecting completely new coins.
+ */
+ repair?: PreviousPayCoins;
+}
+
+export async function computeCoinSelMaxExpirationDate(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ selectedDenom: SelResult,
+): Promise<TalerProtocolTimestamp> {
+ let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
+ for (const dph of Object.keys(selectedDenom)) {
+ const selInfo = selectedDenom[dph];
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ // Compute earliest time that a selected denom
+ // would have its coins auto-refreshed.
+ minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
+ minAutorefreshExecuteThreshold,
+ AbsoluteTime.toProtocolTimestamp(
+ getAutoRefreshExecuteThreshold({
+ stampExpireDeposit: denom.stampExpireDeposit,
+ stampExpireWithdraw: denom.stampExpireWithdraw,
+ }),
+ ),
+ );
+ }
+ return minAutorefreshExecuteThreshold;
+}
+
+export function emptyTallyForPeerPayment(
+ instructedAmount: AmountJson,
+): CoinSelectionTally {
+ const currency = instructedAmount.currency;
+ const zero = Amounts.zeroOfCurrency(currency);
+ return {
+ amountPayRemaining: instructedAmount,
+ customerDepositFees: zero,
+ lastDepositFee: zero,
+ amountDepositFeeLimitRemaining: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
+}
+
+function getGlobalFees(
+ wireDetails: ExchangeWireDetails,
+): ExchangeGlobalFees | undefined {
+ const now = AbsoluteTime.now();
+ for (let gf of wireDetails.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.startDate),
+ AbsoluteTime.fromProtocolTimestamp(gf.endDate),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return gf;
+ }
+ return undefined;
+}
+
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
+export async function selectPeerCoins(
+ wex: WalletExecutionContext,
+ req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+ const instructedAmount = req.instructedAmount;
+ if (Amounts.isZero(instructedAmount)) {
+ // Other parts of the code assume that we have at least
+ // one coin to spend.
+ throw new Error("amount of zero not allowed");
+ }
+
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<SelectPeerCoinsResult> => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ const currency = Amounts.currencyOf(instructedAmount);
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ continue;
+ }
+
+ const avRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ false,
+ );
+
+ if (!avRes) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
+ const r = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
+ );
+
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ avRes.sel,
+ );
+
+ return {
+ type: "success",
+ result: {
+ coins: r.coins,
+ depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ }
+ const insufficientBalanceDetails = await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: undefined,
+ instructedAmount: req.instructedAmount,
+ requiredMinimumAge: undefined,
+ wireMethod: undefined,
+ depositPaytoUri: undefined,
+ },
+ );
+ return {
+ type: "failure",
+ insufficientBalanceDetails,
+ };
+ },
+ );
+}
diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/common.ts
index abba3f7a7..edaba5ba4 100644
--- a/packages/taler-wallet-core/src/operations/common.ts
+++ b/packages/taler-wallet-core/src/common.ts
@@ -19,43 +19,34 @@
*/
import {
AbsoluteTime,
- AgeRestriction,
AmountJson,
Amounts,
- CancellationToken,
+ AsyncFlag,
CoinRefreshRequest,
CoinStatus,
Duration,
ExchangeEntryState,
ExchangeEntryStatus,
- ExchangeListItem,
ExchangeTosStatus,
ExchangeUpdateStatus,
- getErrorDetailFromException,
- j2s,
Logger,
- makeErrorDetail,
- NotificationType,
- OperationErrorInfo,
RefreshReason,
- ScopeInfo,
- ScopeType,
- TalerError,
- TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
TombstoneIdStr,
TransactionIdStr,
- TransactionType,
WalletNotification,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ durationMul,
} from "@gnu-taler/taler-util";
-import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
import {
BackupProviderRecord,
CoinRecord,
DbPreciseTimestamp,
DepositGroupRecord,
- ExchangeDetailsRecord,
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
ExchangeEntryRecord,
@@ -67,19 +58,12 @@ import {
RecoupGroupRecord,
RefreshGroupRecord,
RewardRecord,
- timestampPreciseToDb,
- WalletStoresV1,
+ WalletDbReadWriteTransaction,
WithdrawalGroupRecord,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
-import {
- constructTransactionIdentifier,
- parseTransactionIdentifier,
-} from "./transactions.js";
+ timestampPreciseToDb,
+} from "./db.js";
+import { createRefreshGroup } from "./refresh.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
const logger = new Logger("operations/common.ts");
@@ -94,16 +78,12 @@ export interface CoinsSpendInfo {
}
export async function makeCoinsVisible(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- }>,
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
transactionId: string,
): Promise<void> {
- const coins = await tx.coins.indexes.bySourceTransactionId.getAll(
- transactionId,
- );
+ const coins =
+ await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
for (const coinRecord of coins) {
if (!coinRecord.visible) {
coinRecord.visible = 1;
@@ -126,12 +106,10 @@ export async function makeCoinsVisible(
}
export async function makeCoinAvailable(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- denominations: typeof WalletStoresV1.denominations;
- }>,
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
coinRecord: CoinRecord,
): Promise<void> {
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
@@ -167,13 +145,16 @@ export async function makeCoinAvailable(
}
export async function spendCoins(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- denominations: typeof WalletStoresV1.denominations;
- }>,
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ]
+ >,
csi: CoinsSpendInfo,
): Promise<void> {
if (csi.coinPubs.length != csi.contributions.length) {
@@ -188,8 +169,8 @@ export async function spendCoins(
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
- const denom = await ws.getDenomInfo(
- ws,
+ const denom = await getDenomInfo(
+ wex,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
@@ -250,343 +231,16 @@ export async function spendCoins(
await tx.coinAvailability.put(coinAvailability);
}
- await ws.refreshOps.createRefreshGroup(
- ws,
+ await createRefreshGroup(
+ wex,
tx,
Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs,
csi.refreshReason,
- {
- originatingTransactionId: csi.allocationId,
- },
+ csi.allocationId,
);
}
-/**
- * Convert the task ID for a task that processes a transaction int
- * the ID for the transaction.
- */
-function convertTaskToTransactionId(
- taskId: string,
-): TransactionIdStr | undefined {
- const parsedTaskId = parseTaskIdentifier(taskId);
- switch (parsedTaskId.tag) {
- case PendingTaskType.PeerPullCredit:
- return constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: parsedTaskId.pursePub,
- });
- case PendingTaskType.PeerPullDebit:
- return constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: parsedTaskId.peerPullDebitId,
- });
- // FIXME: This doesn't distinguish internal-withdrawal.
- // Maybe we should have a different task type for that as well?
- // Or maybe transaction IDs should be valid task identifiers?
- case PendingTaskType.Withdraw:
- return constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: parsedTaskId.withdrawalGroupId,
- });
- case PendingTaskType.PeerPushCredit:
- return constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: parsedTaskId.peerPushCreditId,
- });
- case PendingTaskType.Deposit:
- return constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: parsedTaskId.depositGroupId,
- });
- case PendingTaskType.Refresh:
- return constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId: parsedTaskId.refreshGroupId,
- });
- case PendingTaskType.RewardPickup:
- return constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: parsedTaskId.walletRewardId,
- });
- case PendingTaskType.PeerPushDebit:
- return constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: parsedTaskId.pursePub,
- });
- case PendingTaskType.Purchase:
- return constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: parsedTaskId.proposalId,
- });
- default:
- return undefined;
- }
-}
-
-async function makeTransactionRetryNotification(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<typeof WalletStoresV1>,
- pendingTaskId: string,
- e: TalerErrorDetail | undefined,
-): Promise<WalletNotification | undefined> {
- const txId = convertTaskToTransactionId(pendingTaskId);
- if (!txId) {
- return undefined;
- }
- const txState = await ws.getTransactionState(ws, tx, txId);
- if (!txState) {
- return undefined;
- }
- const notif: WalletNotification = {
- type: NotificationType.TransactionStateTransition,
- transactionId: txId,
- oldTxState: txState,
- newTxState: txState,
- };
- if (e) {
- notif.errorInfo = {
- code: e.code as number,
- hint: e.hint,
- };
- }
- return notif;
-}
-
-async function makeExchangeRetryNotification(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<typeof WalletStoresV1>,
- pendingTaskId: string,
- e: TalerErrorDetail | undefined,
-): Promise<WalletNotification | undefined> {
- logger.info("making exchange retry notification");
- const parsedTaskId = parseTaskIdentifier(pendingTaskId);
- if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) {
- throw Error("invalid task identifier");
- }
- const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl);
-
- if (!rec) {
- logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`);
- return undefined;
- }
-
- const notif: WalletNotification = {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: parsedTaskId.exchangeBaseUrl,
- oldExchangeState: getExchangeState(rec),
- newExchangeState: getExchangeState(rec),
- };
- if (e) {
- notif.errorInfo = {
- code: e.code as number,
- hint: e.hint,
- };
- }
- return notif;
-}
-
-/**
- * Generate an appropriate error transition notification
- * for applicable tasks.
- *
- * Namely, transition notifications are generated for:
- * - exchange update errors
- * - transactions
- */
-async function taskToRetryNotification(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<typeof WalletStoresV1>,
- pendingTaskId: string,
- e: TalerErrorDetail | undefined,
-): Promise<WalletNotification | undefined> {
- const parsedTaskId = parseTaskIdentifier(pendingTaskId);
-
- switch (parsedTaskId.tag) {
- case PendingTaskType.ExchangeUpdate:
- return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
- case PendingTaskType.PeerPullCredit:
- case PendingTaskType.PeerPullDebit:
- case PendingTaskType.Withdraw:
- case PendingTaskType.PeerPushCredit:
- case PendingTaskType.Deposit:
- case PendingTaskType.Refresh:
- case PendingTaskType.RewardPickup:
- case PendingTaskType.PeerPushDebit:
- case PendingTaskType.Purchase:
- return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
- case PendingTaskType.Backup:
- case PendingTaskType.ExchangeCheckRefresh:
- case PendingTaskType.Recoup:
- return undefined;
- }
-}
-
-async function storePendingTaskError(
- ws: InternalWalletState,
- pendingTaskId: string,
- e: TalerErrorDetail,
-): Promise<void> {
- logger.info(`storing pending task error for ${pendingTaskId}`);
- const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
- let retryRecord = await tx.operationRetries.get(pendingTaskId);
- if (!retryRecord) {
- retryRecord = {
- id: pendingTaskId,
- lastError: e,
- retryInfo: DbRetryInfo.reset(),
- };
- } else {
- retryRecord.lastError = e;
- retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
- }
- await tx.operationRetries.put(retryRecord);
- return taskToRetryNotification(ws, tx, pendingTaskId, e);
- });
- if (maybeNotification) {
- ws.notify(maybeNotification);
- }
-}
-
-export async function resetPendingTaskTimeout(
- ws: InternalWalletState,
- pendingTaskId: string,
-): Promise<void> {
- const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
- let retryRecord = await tx.operationRetries.get(pendingTaskId);
- if (retryRecord) {
- // Note that we don't reset the lastError, it should still be visible
- // while the retry runs.
- retryRecord.retryInfo = DbRetryInfo.reset();
- await tx.operationRetries.put(retryRecord);
- }
- return taskToRetryNotification(ws, tx, pendingTaskId, undefined);
- });
- if (maybeNotification) {
- ws.notify(maybeNotification);
- }
-}
-
-async function storePendingTaskPending(
- ws: InternalWalletState,
- pendingTaskId: string,
-): Promise<void> {
- const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
- let retryRecord = await tx.operationRetries.get(pendingTaskId);
- let hadError = false;
- if (!retryRecord) {
- retryRecord = {
- id: pendingTaskId,
- retryInfo: DbRetryInfo.reset(),
- };
- } else {
- if (retryRecord.lastError) {
- hadError = true;
- }
- delete retryRecord.lastError;
- retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
- }
- await tx.operationRetries.put(retryRecord);
- if (hadError) {
- return taskToRetryNotification(ws, tx, pendingTaskId, undefined);
- } else {
- return undefined;
- }
- });
- if (maybeNotification) {
- ws.notify(maybeNotification);
- }
-}
-
-async function storePendingTaskFinished(
- ws: InternalWalletState,
- pendingTaskId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => [x.operationRetries])
- .runReadWrite(async (tx) => {
- await tx.operationRetries.delete(pendingTaskId);
- });
-}
-
-export async function runTaskWithErrorReporting(
- ws: InternalWalletState,
- opId: TaskId,
- f: () => Promise<TaskRunResult>,
-): Promise<TaskRunResult> {
- let maybeError: TalerErrorDetail | undefined;
- try {
- const resp = await f();
- switch (resp.type) {
- case TaskRunResultType.Error:
- await storePendingTaskError(ws, opId, resp.errorDetail);
- return resp;
- case TaskRunResultType.Finished:
- await storePendingTaskFinished(ws, opId);
- return resp;
- case TaskRunResultType.Pending:
- await storePendingTaskPending(ws, opId);
- return resp;
- case TaskRunResultType.Longpoll:
- return resp;
- }
- } catch (e) {
- if (e instanceof CryptoApiStoppedError) {
- if (ws.stopped) {
- logger.warn("crypto API stopped during shutdown, ignoring error");
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- {},
- "Crypto API stopped during shutdown",
- ),
- };
- }
- }
- if (e instanceof TalerError) {
- logger.warn("operation processed resulted in error");
- logger.warn(`error was: ${j2s(e.errorDetail)}`);
- maybeError = e.errorDetail;
- await storePendingTaskError(ws, opId, maybeError!);
- return {
- type: TaskRunResultType.Error,
- errorDetail: e.errorDetail,
- };
- } else if (e instanceof Error) {
- // This is a bug, as we expect pending operations to always
- // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
- // or return something.
- logger.error(`Uncaught exception: ${e.message}`);
- logger.error(`Stack: ${e.stack}`);
- maybeError = makeErrorDetail(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- {
- stack: e.stack,
- },
- `unexpected exception (message: ${e.message})`,
- );
- await storePendingTaskError(ws, opId, maybeError);
- return {
- type: TaskRunResultType.Error,
- errorDetail: maybeError,
- };
- } else {
- logger.error("Uncaught exception, value is not even an error.");
- maybeError = makeErrorDetail(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- {},
- `unexpected exception (not even an error)`,
- );
- await storePendingTaskError(ws, opId, maybeError);
- return {
- type: TaskRunResultType.Error,
- errorDetail: maybeError,
- };
- }
- }
-}
-
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
@@ -629,6 +283,8 @@ export function getExchangeUpdateStatusFromRecord(
return ExchangeUpdateStatus.ReadyUpdate;
case ExchangeEntryDbUpdateStatus.Suspended:
return ExchangeUpdateStatus.Suspended;
+ default:
+ assertUnreachable(r.updateStatus);
}
}
@@ -642,6 +298,8 @@ export function getExchangeEntryStatusFromRecord(
return ExchangeEntryStatus.Preset;
case ExchangeEntryDbRecordStatus.Used:
return ExchangeEntryStatus.Used;
+ default:
+ assertUnreachable(r.entryStatus);
}
}
@@ -657,83 +315,6 @@ export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
};
}
-export function makeExchangeListItem(
- r: ExchangeEntryRecord,
- exchangeDetails: ExchangeDetailsRecord | undefined,
- lastError: TalerErrorDetail | undefined,
-): ExchangeListItem {
- const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
- ? {
- error: lastError,
- }
- : undefined;
-
- let scopeInfo: ScopeInfo | undefined = undefined;
- if (exchangeDetails) {
- // FIXME: Look up actual scope info.
- scopeInfo = {
- currency: exchangeDetails.currency,
- type: ScopeType.Exchange,
- url: r.baseUrl,
- };
- }
-
- return {
- exchangeBaseUrl: r.baseUrl,
- currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
- exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
- exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
- tosStatus: getExchangeTosStatusFromRecord(r),
- ageRestrictionOptions: exchangeDetails?.ageMask
- ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
- : [],
- paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
- lastUpdateErrorInfo,
- scopeInfo,
- };
-}
-
-export interface LongpollResult {
- ready: boolean;
-}
-
-export function runLongpollAsync(
- ws: InternalWalletState,
- retryTag: string,
- reqFn: (ct: CancellationToken) => Promise<LongpollResult>,
-): void {
- const asyncFn = async () => {
- if (ws.stopped) {
- logger.trace("not long-polling reserve, wallet already stopped");
- await storePendingTaskPending(ws, retryTag);
- return;
- }
- const cts = CancellationToken.create();
- let res: { ready: boolean } | undefined = undefined;
- try {
- ws.activeLongpoll[retryTag] = {
- cancel: () => {
- logger.trace("cancel of reserve longpoll requested");
- cts.cancel();
- },
- };
- res = await reqFn(cts.token);
- } catch (e) {
- const errDetail = getErrorDetailFromException(e);
- logger.warn(`got error during long-polling: ${j2s(errDetail)}`);
- await storePendingTaskError(ws, retryTag, errDetail);
- return;
- } finally {
- delete ws.activeLongpoll[retryTag];
- }
- if (!res.ready) {
- await storePendingTaskPending(ws, retryTag);
- }
- ws.workAvailable.trigger();
- };
- asyncFn();
-}
-
export type ParsedTombstone =
| {
tag: TombstoneTag.DeleteWithdrawalGroup;
@@ -768,7 +349,7 @@ export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
* Uniform interface for a particular wallet transaction.
*/
export interface TransactionManager {
- get taskId(): TaskId;
+ get taskId(): TaskIdStr;
get transactionId(): TransactionIdStr;
fail(): Promise<void>;
abort(): Promise<void>;
@@ -779,31 +360,64 @@ export interface TransactionManager {
export enum TaskRunResultType {
Finished = "finished",
- Pending = "pending",
+ Backoff = "backoff",
+ Progress = "progress",
Error = "error",
- Longpoll = "longpoll",
+ LongpollReturnedPending = "longpoll-returned-pending",
+ ScheduleLater = "schedule-later",
}
export type TaskRunResult =
| TaskRunFinishedResult
| TaskRunErrorResult
- | TaskRunLongpollResult
- | TaskRunPendingResult;
+ | TaskRunBackoffResult
+ | TaskRunProgressResult
+ | TaskRunLongpollReturnedPendingResult
+ | TaskRunScheduleLaterResult;
export namespace TaskRunResult {
+ /**
+ * Task is finished and does not need to be processed again.
+ */
export function finished(): TaskRunResult {
return {
type: TaskRunResultType.Finished,
};
}
- export function pending(): TaskRunResult {
+ /**
+ * Task is waiting for something, should be invoked
+ * again with exponentiall back-off until some other
+ * result is returned.
+ */
+ export function backoff(): TaskRunResult {
return {
- type: TaskRunResultType.Pending,
+ type: TaskRunResultType.Backoff,
};
}
- export function longpoll(): TaskRunResult {
+ /**
+ * Task made progress and should be processed again.
+ */
+ export function progress(): TaskRunResult {
return {
- type: TaskRunResultType.Longpoll,
+ type: TaskRunResultType.Progress,
+ };
+ }
+ /**
+ * Run the task again at a fixed time in the future.
+ */
+ export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
+ return {
+ type: TaskRunResultType.ScheduleLater,
+ runAt,
+ };
+ }
+ /**
+ * Longpolling returned, but what we're waiting for
+ * is still pending on the other side.
+ */
+ export function longpollReturnedPending(): TaskRunLongpollReturnedPendingResult {
+ return {
+ type: TaskRunResultType.LongpollReturnedPending,
};
}
}
@@ -812,8 +426,21 @@ export interface TaskRunFinishedResult {
type: TaskRunResultType.Finished;
}
-export interface TaskRunPendingResult {
- type: TaskRunResultType.Pending;
+export interface TaskRunBackoffResult {
+ type: TaskRunResultType.Backoff;
+}
+
+export interface TaskRunProgressResult {
+ type: TaskRunResultType.Progress;
+}
+
+export interface TaskRunScheduleLaterResult {
+ type: TaskRunResultType.ScheduleLater;
+ runAt: AbsoluteTime;
+}
+
+export interface TaskRunLongpollReturnedPendingResult {
+ type: TaskRunResultType.LongpollReturnedPending;
}
export interface TaskRunErrorResult {
@@ -821,10 +448,6 @@ export interface TaskRunErrorResult {
errorDetail: TalerErrorDetail;
}
-export interface TaskRunLongpollResult {
- type: TaskRunResultType.Longpoll;
-}
-
export interface DbRetryInfo {
firstTry: DbPreciseTimestamp;
nextRetry: DbPreciseTimestamp;
@@ -869,25 +492,28 @@ function updateTimeout(
r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
}
-export namespace DbRetryInfo {
- export function getDuration(
- r: DbRetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
- ): Duration {
- if (!r) {
- // If we don't have any retry info, run immediately.
- return { d_ms: 0 };
- }
- if (p.backoffDelta.d_ms === "forever") {
- return { d_ms: "forever" };
- }
- const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- return {
- d_ms:
- p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t),
- };
+export function computeDbBackoff(retryCounter: number): DbPreciseTimestamp {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ const p = defaultRetryPolicy;
+ if (p.backoffDelta.d_ms === "forever") {
+ throw Error("assertion failed");
}
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter);
+
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
+}
+
+export namespace DbRetryInfo {
export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
const now = TalerPreciseTimestamp.now();
const info: DbRetryInfo = {
@@ -914,6 +540,24 @@ export namespace DbRetryInfo {
}
/**
+ * Timestamp after which the wallet would do an auto-refresh.
+ */
+export function getAutoRefreshExecuteThreshold(d: {
+ stampExpireWithdraw: TalerProtocolTimestamp;
+ stampExpireDeposit: TalerProtocolTimestamp;
+}): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireWithdraw,
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireDeposit,
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.5);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
* Parsed representation of task identifiers.
*/
export type ParsedTaskIdentifier =
@@ -924,7 +568,6 @@ export type ParsedTaskIdentifier =
| { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
| { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
| { tag: PendingTaskType.Deposit; depositGroupId: string }
- | { tag: PendingTaskType.ExchangeCheckRefresh; exchangeBaseUrl: string }
| { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
| { tag: PendingTaskType.PeerPullCredit; pursePub: string }
| { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
@@ -947,8 +590,6 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
case PendingTaskType.Deposit:
return { tag: type, depositGroupId: rest[0] };
- case PendingTaskType.ExchangeCheckRefresh:
- return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
case PendingTaskType.ExchangeUpdate:
return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
case PendingTaskType.PeerPullCredit:
@@ -974,96 +615,209 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
}
}
-export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId {
+export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
switch (p.tag) {
case PendingTaskType.Backup:
- return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId;
+ return `${p.tag}:${p.backupProviderBaseUrl}` as TaskIdStr;
case PendingTaskType.Deposit:
- return `${p.tag}:${p.depositGroupId}` as TaskId;
- case PendingTaskType.ExchangeCheckRefresh:
- return `${p.tag}:${p.exchangeBaseUrl}` as TaskId;
+ return `${p.tag}:${p.depositGroupId}` as TaskIdStr;
case PendingTaskType.ExchangeUpdate:
- return `${p.tag}:${p.exchangeBaseUrl}` as TaskId;
+ return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
case PendingTaskType.PeerPullDebit:
- return `${p.tag}:${p.peerPullDebitId}` as TaskId;
+ return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr;
case PendingTaskType.PeerPushCredit:
- return `${p.tag}:${p.peerPushCreditId}` as TaskId;
+ return `${p.tag}:${p.peerPushCreditId}` as TaskIdStr;
case PendingTaskType.PeerPullCredit:
- return `${p.tag}:${p.pursePub}` as TaskId;
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
case PendingTaskType.PeerPushDebit:
- return `${p.tag}:${p.pursePub}` as TaskId;
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
case PendingTaskType.Purchase:
- return `${p.tag}:${p.proposalId}` as TaskId;
+ return `${p.tag}:${p.proposalId}` as TaskIdStr;
case PendingTaskType.Recoup:
- return `${p.tag}:${p.recoupGroupId}` as TaskId;
+ return `${p.tag}:${p.recoupGroupId}` as TaskIdStr;
case PendingTaskType.Refresh:
- return `${p.tag}:${p.refreshGroupId}` as TaskId;
+ return `${p.tag}:${p.refreshGroupId}` as TaskIdStr;
case PendingTaskType.RewardPickup:
- return `${p.tag}:${p.walletRewardId}` as TaskId;
+ return `${p.tag}:${p.walletRewardId}` as TaskIdStr;
case PendingTaskType.Withdraw:
- return `${p.tag}:${p.withdrawalGroupId}` as TaskId;
+ return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr;
default:
assertUnreachable(p);
}
}
export namespace TaskIdentifiers {
- export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId {
- return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId;
+ export function forWithdrawal(wg: WithdrawalGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskIdStr;
}
- export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId {
+ export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskIdStr {
return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
exch.baseUrl,
- )}` as TaskId;
+ )}` as TaskIdStr;
}
- export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
+ export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskIdStr {
return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
exchBaseUrl,
- )}` as TaskId;
- }
- export function forExchangeCheckRefresh(exch: ExchangeEntryRecord): TaskId {
- return `${PendingTaskType.ExchangeCheckRefresh}:${encodeURIComponent(
- exch.baseUrl,
- )}` as TaskId;
+ )}` as TaskIdStr;
}
- export function forTipPickup(tipRecord: RewardRecord): TaskId {
- return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId;
+ export function forTipPickup(tipRecord: RewardRecord): TaskIdStr {
+ return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr;
}
- export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId {
- return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId;
+ export function forRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskIdStr;
}
- export function forPay(purchaseRecord: PurchaseRecord): TaskId {
- return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId;
+ export function forPay(purchaseRecord: PurchaseRecord): TaskIdStr {
+ return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskIdStr;
}
- export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId {
- return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId;
+ export function forRecoup(recoupRecord: RecoupGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskIdStr;
}
- export function forDeposit(depositRecord: DepositGroupRecord): TaskId {
- return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId;
+ export function forDeposit(depositRecord: DepositGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskIdStr;
}
- export function forBackup(backupRecord: BackupProviderRecord): TaskId {
+ export function forBackup(backupRecord: BackupProviderRecord): TaskIdStr {
return `${PendingTaskType.Backup}:${encodeURIComponent(
backupRecord.baseUrl,
- )}` as TaskId;
+ )}` as TaskIdStr;
}
export function forPeerPushPaymentInitiation(
ppi: PeerPushDebitRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId;
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskIdStr;
}
export function forPeerPullPaymentInitiation(
ppi: PeerPullCreditRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId;
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskIdStr;
}
export function forPeerPullPaymentDebit(
ppi: PeerPullPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId;
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr;
}
export function forPeerPushCredit(
ppi: PeerPushPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId;
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr;
+ }
+}
+
+/**
+ * Result of a transaction transition.
+ */
+export enum TransitionResultType {
+ Transition = 1,
+ Stay = 2,
+ Delete = 3,
+}
+
+export type TransitionResult<R> =
+ | { type: TransitionResultType.Stay }
+ | { type: TransitionResultType.Transition; rec: R }
+ | { type: TransitionResultType.Delete };
+
+export const TransitionResult = {
+ stay<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Stay };
+ },
+ delete<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Delete };
+ },
+ transition<T>(rec: T): TransitionResult<T> {
+ return {
+ type: TransitionResultType.Transition,
+ rec,
+ };
+ },
+};
+
+/**
+ * Transaction context.
+ * Uniform interface to all transactions.
+ */
+export interface TransactionContext {
+ get taskId(): TaskIdStr | undefined;
+ get transactionId(): TransactionIdStr;
+ abortTransaction(): Promise<void>;
+ suspendTransaction(): Promise<void>;
+ resumeTransaction(): Promise<void>;
+ failTransaction(): Promise<void>;
+ deleteTransaction(): Promise<void>;
+}
+
+/**
+ * Type and schema definitions for pending tasks in the wallet.
+ *
+ * These are only used internally, and are not part of the stable public
+ * interface to the wallet.
+ */
+
+export enum PendingTaskType {
+ ExchangeUpdate = "exchange-update",
+ Purchase = "purchase",
+ Refresh = "refresh",
+ Recoup = "recoup",
+ RewardPickup = "reward-pickup",
+ Withdraw = "withdraw",
+ Deposit = "deposit",
+ Backup = "backup",
+ PeerPushDebit = "peer-push-debit",
+ PeerPullCredit = "peer-pull-credit",
+ PeerPushCredit = "peer-push-credit",
+ PeerPullDebit = "peer-pull-debit",
+}
+
+declare const __taskIdStr: unique symbol;
+export type TaskIdStr = string & { [__taskIdStr]: true };
+
+/**
+ * Wait until the wallet is in a particular state.
+ *
+ * Two functions must be provided:
+ * 1. checkState, which checks if the wallet is in the
+ * desired state.
+ * 2. filterNotification, which checks whether a notification
+ * might have lead to a state change.
+ */
+export async function genericWaitForState(
+ wex: WalletExecutionContext,
+ args: {
+ checkState: () => Promise<boolean>;
+ filterNotification: (notif: WalletNotification) => boolean;
+ },
+): Promise<void> {
+ await wex.taskScheduler.ensureRunning();
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const flag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (args.filterNotification(notif)) {
+ flag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ flag.raise();
+ });
+
+ try {
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ if (await args.checkState()) {
+ return;
+ }
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
}
}
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
index 7c6b142fb..2a2958a71 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -214,6 +214,10 @@ export interface TalerCryptoInterface {
signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>;
+ signReserveHistoryReq(
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse>;
+
signPurseDeposits(
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse>;
@@ -438,6 +442,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<SignCoinHistoryResponse> {
throw new Error("Function not implemented.");
},
+ signReserveHistoryReq: function (
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ throw new Error("Function not implemented.");
+ },
};
export type WithArg<X> = X extends (req: infer T) => infer R
@@ -475,6 +484,15 @@ export interface SignPurseCreationRequest {
minAge: number;
}
+export interface SignReserveHistoryReqRequest {
+ reservePriv: string;
+ startOffset: number;
+}
+
+export interface SignReserveHistoryReqResponse {
+ sig: string;
+}
+
export interface SpendCoinDetails {
coinPub: string;
coinPriv: string;
@@ -1134,7 +1152,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
depositInfo.ageCommitmentProof.commitment,
);
hAgeCommitment = decodeCrock(ach);
- if (depositInfo.requiredMinimumAge != null) {
+ if (depositInfo.requiredMinimumAge) {
minimumAgeSig = encodeCrock(
AgeRestriction.commitmentAttest(
depositInfo.ageCommitmentProof,
@@ -1184,7 +1202,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
},
};
- if (depositInfo.requiredMinimumAge != null) {
+ if (depositInfo.requiredMinimumAge) {
// These are only required by the merchant
s.minimum_age_sig = minimumAgeSig;
s.age_commitment =
@@ -1468,15 +1486,12 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
const deposits: PurseDeposit[] = [];
for (const c of req.coins) {
- let haveAch: boolean;
let maybeAch: Uint8Array;
if (c.ageCommitmentProof) {
- haveAch = true;
maybeAch = decodeCrock(
AgeRestriction.hashCommitment(c.ageCommitmentProof.commitment),
);
} else {
- haveAch = false;
maybeAch = new Uint8Array(32);
}
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
@@ -1733,6 +1748,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
sig: sigResp.sig,
};
},
+ async signReserveHistoryReq(
+ tci: TalerCryptoInterfaceR,
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ const reserveHistoryBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_HISTORY,
+ )
+ .put(bufferForUint64(req.startOffset))
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveHistoryBlob),
+ priv: req.reservePriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
};
export interface EddsaSignRequest {
diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
index 192e9cda1..f86163723 100644
--- a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
@@ -23,10 +23,16 @@
/**
* Imports.
*/
-import { j2s, Logger, TalerErrorCode } from "@gnu-taler/taler-util";
-import { TalerError } from "@gnu-taler/taler-util";
-import { openPromise } from "../../util/promiseUtils.js";
-import { timer, performanceNow, TimerHandle } from "../../util/timer.js";
+import {
+ j2s,
+ Logger,
+ openPromise,
+ performanceNow,
+ TalerError,
+ TalerErrorCode,
+ timer,
+ TimerHandle,
+} from "@gnu-taler/taler-util";
import { nullCrypto, TalerCryptoInterface } from "../cryptoImplementation.js";
import { CryptoWorker } from "./cryptoWorkerInterface.js";
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 6f6aad256..44c241aed 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021-2022 Taler Systems S.A.
+ (C) 2021-2024 Taler Systems S.A.
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
@@ -19,7 +19,6 @@
*/
import {
Event,
- GlobalIDB,
IDBDatabase,
IDBFactory,
IDBObjectStore,
@@ -34,11 +33,14 @@ import {
AmountString,
Amounts,
AttentionInfo,
+ BackupProviderTerms,
+ CancellationToken,
Codec,
CoinEnvelope,
CoinPublicKeyString,
CoinRefreshRequest,
CoinStatus,
+ DenomLossEventType,
DenomSelectionState,
DenominationInfo,
DenominationPubKey,
@@ -48,24 +50,24 @@ import {
ExchangeGlobalFees,
HashCodeString,
Logger,
- PayCoinSelection,
RefreshReason,
TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
+ Transaction,
TransactionIdStr,
UnblindedSignature,
WireInfo,
WithdrawalExchangeAccountDetails,
codecForAny,
} from "@gnu-taler/taler-util";
-import { DbRetryInfo, TaskIdentifiers } from "./operations/common.js";
+import { DbRetryInfo, TaskIdentifiers } from "./common.js";
import {
DbAccess,
+ DbAccessImpl,
DbReadOnlyTransaction,
DbReadWriteTransaction,
- GetReadWriteAccess,
IndexDescriptor,
StoreDescriptor,
StoreNames,
@@ -73,8 +75,9 @@ import {
describeContents,
describeIndex,
describeStore,
+ describeStoreV2,
openDatabase,
-} from "./util/query.js";
+} from "./query.js";
/**
* This file contains the database schema of the Taler wallet together
@@ -148,7 +151,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices
* are added.
*/
-export const WALLET_DB_MINOR_VERSION = 1;
+export const WALLET_DB_MINOR_VERSION = 10;
declare const symDbProtocolTimestamp: unique symbol;
@@ -255,6 +258,16 @@ export function timestampOptionalAbsoluteFromDb(
*/
/**
+ * First possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000;
+
+/**
+ * LAST possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff;
+
+/**
* Status of a withdrawal.
*/
export enum WithdrawalGroupStatus {
@@ -285,6 +298,11 @@ export enum WithdrawalGroupStatus {
SuspendedReady = 0x0110_0004,
/**
+ * Proposed to the user, has can choose to accept/refuse.
+ */
+ DialogProposed = 0x0101_0000,
+
+ /**
* We are telling the bank that we don't want to complete
* the withdrawal!
*/
@@ -325,15 +343,22 @@ export enum WithdrawalGroupStatus {
AbortedExchange = 0x0503_0001,
AbortedBank = 0x0503_0002,
-}
-/**
- * Status range of nonfinal withdrawal groups.
- */
-export const withdrawalGroupNonfinalRange = GlobalIDB.KeyRange.bound(
- WithdrawalGroupStatus.PendingRegisteringBank,
- WithdrawalGroupStatus.PendingAml,
-);
+ /**
+ * User didn't refused the withdrawal.
+ */
+ AbortedUserRefused = 0x0503_0003,
+
+ /**
+ * Another wallet confirmed the withdrawal
+ * (by POSTing the reserve pub to the bank)
+ * before we had the chance.
+ *
+ * In this situation, we'll let the other wallet continue
+ * and give up ourselves.
+ */
+ AbortedOtherWallet = 0x0503_0004,
+}
/**
* Extra info about a withdrawal that is used
@@ -351,7 +376,7 @@ export interface ReserveBankInfo {
/**
* Exchange payto URI that the bank will use to fund the reserve.
*/
- exchangePaytoUri: string;
+ exchangePaytoUri?: string;
/**
* Time when the information about this reserve was posted to the bank.
@@ -368,6 +393,8 @@ export interface ReserveBankInfo {
* Set to undefined if not confirmed yet.
*/
timestampBankConfirmed: DbPreciseTimestamp | undefined;
+
+ wireTypes: string[] | undefined;
}
/**
@@ -484,6 +511,13 @@ export interface DenominationRecord {
isRevoked: boolean;
/**
+ * If set to true, the exchange announced that the private key for this
+ * denomination is lost. Thus it can't be used to sign new coins
+ * during withdrawal/refresh/..., but the coins can still be spent.
+ */
+ isLost?: boolean;
+
+ /**
* Base URL of the exchange.
*/
exchangeBaseUrl: string;
@@ -493,12 +527,6 @@ export interface DenominationRecord {
* on the denomination.
*/
exchangeMasterPub: string;
-
- /**
- * Latest list issue date of the "/keys" response
- * that includes this denomination.
- */
- listIssueDate: DbProtocolTimestamp;
}
export namespace DenominationRecord {
@@ -645,6 +673,12 @@ export interface ExchangeEntryRecord {
updateStatus: ExchangeEntryDbUpdateStatus;
/**
+ * If set to true, the next update to the exchange
+ * status will request /keys with no-cache headers set.
+ */
+ cachebreakNextUpdate?: boolean;
+
+ /**
* Etag of the current ToS of the exchange.
*/
tosCurrentEtag: string | undefined;
@@ -663,6 +697,8 @@ export interface ExchangeEntryRecord {
*/
nextUpdateStamp: DbPreciseTimestamp;
+ updateRetryCounter?: number;
+
lastKeysEtag: string | undefined;
/**
@@ -678,12 +714,23 @@ export interface ExchangeEntryRecord {
* receiving P2P payments.
*/
currentMergeReserveRowId?: number;
+
+ /**
+ * Defaults to false.
+ */
+ peerPaymentsDisabled?: boolean;
+
+ /**
+ * Defaults to false.
+ */
+ noFees?: boolean;
}
export enum PlanchetStatus {
Pending = 0x0100_0000,
KycRequired = 0x0100_0001,
WithdrawalDone = 0x0500_000,
+ AbortedReplaced = 0x0503_0001,
}
/**
@@ -954,6 +1001,7 @@ export enum RewardRecordStatus {
DialogAccept = 0x0101_0000,
Done = 0x0500_0000,
Aborted = 0x0500_0000,
+ Failed = 0x0501_000,
}
export enum RefreshCoinStatus {
@@ -990,12 +1038,11 @@ export enum DepositElementStatus {
RefundFailed = 0x0501_0000,
}
-/**
- * Additional information about the reason of a refresh.
- */
-export interface RefreshReasonDetails {
- originatingTransactionId?: string;
- proposalId?: string;
+export interface RefreshGroupPerExchangeInfo {
+ /**
+ * (Expected) output once the refresh group succeeded.
+ */
+ outputEffective: AmountString;
}
/**
@@ -1022,10 +1069,7 @@ export interface RefreshGroupRecord {
*/
reason: RefreshReason;
- /**
- * Extra information depending on the reason.
- */
- reasonDetails?: RefreshReasonDetails;
+ originatingTransactionId?: string;
oldCoinPubs: string[];
@@ -1033,6 +1077,8 @@ export interface RefreshGroupRecord {
expectedOutputPerCoin: AmountString[];
+ infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>;
+
/**
* Flag for each coin whether refreshing finished.
* If a coin can't be refreshed (remaining value too small),
@@ -1137,7 +1183,7 @@ export enum PurchaseStatus {
SuspendedQueryingAutoRefund = 0x0110_0004,
PendingAcceptRefund = 0x0100_0005,
- SuspendedPendingAcceptRefund = 0x0100_0005,
+ SuspendedPendingAcceptRefund = 0x0110_0005,
/**
* Proposal downloaded, but the user needs to accept/reject it.
@@ -1161,6 +1207,13 @@ export enum PurchaseStatus {
FailedClaim = 0x0501_0000,
/**
+ * Tried to abort, but aborting failed or was cancelled.
+ */
+ FailedAbort = 0x0501_0001,
+
+ FailedPaidByOther = 0x0501_0002,
+
+ /**
* Payment was successful.
*/
Done = 0x0500_0000,
@@ -1175,12 +1228,9 @@ export enum PurchaseStatus {
*/
AbortedIncompletePayment = 0x0503_0000,
- /**
- * Tried to abort, but aborting failed or was cancelled.
- */
- FailedAbort = 0x0501_0001,
+ AbortedRefunded = 0x0503_0001,
- AbortedRefunded = 0x0503_0000,
+ AbortedOrderDeleted = 0x0503_0002,
}
/**
@@ -1195,10 +1245,21 @@ export interface ProposalDownloadInfo {
contractTermsMerchantSig: string;
}
+export interface DbCoinSelection {
+ coinPubs: string[];
+ coinContributions: AmountString[];
+}
+
export interface PurchasePayInfo {
- payCoinSelection: PayCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelection?: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelectionUid?: string;
totalPayCost: AmountString;
- payCoinSelectionUid: string;
}
/**
@@ -1278,8 +1339,9 @@ export interface PurchaseRecord {
posConfirmation: string | undefined;
/**
- * This purchase was created by sharing nonce or
- * did the wallet made the nonce public
+ * This purchase was created by reading
+ * a payment share or the wallet
+ * the nonce public by a payment share
*/
shared: boolean;
@@ -1321,6 +1383,9 @@ export enum ConfigRecordKey {
WalletBackupState = "walletBackupState",
CurrencyDefaultsApplied = "currencyDefaultsApplied",
DevMode = "devMode",
+ // Only for testing, do not use!
+ TestLoopTx = "testTxLoop",
+ LastInitInfo = "lastInitInfo",
}
/**
@@ -1333,7 +1398,8 @@ export type ConfigRecord =
value: WalletBackupConfState;
}
| { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean }
- | { key: ConfigRecordKey.DevMode; value: boolean };
+ | { key: ConfigRecordKey.TestLoopTx; value: number }
+ | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp };
export interface WalletBackupConfState {
deviceId: string;
@@ -1498,7 +1564,7 @@ export interface WithdrawalGroupRecord {
/**
* Amount that was sent by the user to fund the reserve.
*/
- instructedAmount: AmountString;
+ instructedAmount?: AmountString;
/**
* Amount that was observed when querying the reserve that
@@ -1515,7 +1581,7 @@ export interface WithdrawalGroupRecord {
* (Initial amount confirmed by the user, might differ with denomSel
* on reselection.)
*/
- rawWithdrawalAmount: AmountString;
+ rawWithdrawalAmount?: AmountString;
/**
* Amount that will be added to the balance when the withdrawal succeeds.
@@ -1523,12 +1589,12 @@ export interface WithdrawalGroupRecord {
* (Initial amount confirmed by the user, might differ with denomSel
* on reselection.)
*/
- effectiveWithdrawalAmount: AmountString;
+ effectiveWithdrawalAmount?: AmountString;
/**
* Denominations selected for withdrawal.
*/
- denomsSel: DenomSelectionState;
+ denomsSel?: DenomSelectionState;
/**
* UID of the denomination selection.
@@ -1552,6 +1618,14 @@ export interface BankWithdrawUriRecord {
reservePub: string;
}
+export enum RecoupOperationStatus {
+ Pending = 0x0100_0000,
+ Suspended = 0x0110_0000,
+
+ Finished = 0x0500_000,
+ Failed = 0x0501_000,
+}
+
/**
* Status of recoup operations that were grouped together.
*
@@ -1566,6 +1640,8 @@ export interface RecoupGroupRecord {
exchangeBaseUrl: string;
+ operationStatus: RecoupOperationStatus;
+
timestampStarted: DbPreciseTimestamp;
timestampFinished: DbPreciseTimestamp | undefined;
@@ -1608,12 +1684,6 @@ export type BackupProviderState =
tag: BackupProviderStateTag.Retrying;
};
-export interface BackupProviderTerms {
- supportedProtocolVersion: string;
- annualFee: AmountString;
- storageLimitInMegabytes: number;
-}
-
export interface BackupProviderRecord {
/**
* Base URL of the provider.
@@ -1694,11 +1764,6 @@ export enum DepositOperationStatus {
Aborted = 0x0503_0000,
}
-export const depositOperationNonfinalStatusRange = GlobalIDB.KeyRange.bound(
- DepositOperationStatus.PendingDeposit,
- DepositOperationStatus.PendingKyc,
-);
-
export interface DepositTrackingInfo {
// Raw wire transfer identifier of the deposit.
wireTransferId: string;
@@ -1712,6 +1777,14 @@ export interface DepositTrackingInfo {
exchangePub: string;
}
+export interface DepositInfoPerExchange {
+ /**
+ * Expected effective amount that will be deposited
+ * from coins of this exchange.
+ */
+ amountEffective: AmountString;
+}
+
/**
* Group of deposits made by the wallet.
*/
@@ -1744,9 +1817,9 @@ export interface DepositGroupRecord {
contractTermsHash: string;
- payCoinSelection: PayCoinSelection;
+ payCoinSelection?: DbCoinSelection;
- payCoinSelectionUid: string;
+ payCoinSelectionUid?: string;
totalPayCost: AmountString;
@@ -1761,7 +1834,9 @@ export interface DepositGroupRecord {
operationStatus: DepositOperationStatus;
- statusPerCoin: DepositElementStatus[];
+ statusPerCoin?: DepositElementStatus[];
+
+ infoPerExchange?: Record<string, DepositInfoPerExchange>;
/**
* When the deposit transaction was aborted and
@@ -1820,7 +1895,7 @@ export enum PeerPushDebitStatus {
Expired = 0x0502_0000,
}
-export interface PeerPushPaymentCoinSelection {
+export interface DbPeerPushPaymentCoinSelection {
contributions: AmountString[];
coinPubs: CoinPublicKeyString[];
}
@@ -1841,7 +1916,7 @@ export interface PeerPushDebitRecord {
totalCost: AmountString;
- coinSel: PeerPushPaymentCoinSelection;
+ coinSel?: DbPeerPushPaymentCoinSelection;
contractTermsHash: HashCodeString;
@@ -2153,6 +2228,11 @@ export interface CoinAvailabilityRecord {
* a final state.
*/
visibleCoinCount: number;
+
+ /**
+ * Number of coins that we expect to obtain via a pending refresh.
+ */
+ pendingRefreshOutputCount?: number;
}
export interface ContractTermsRecord {
@@ -2193,26 +2273,12 @@ export interface DbAuditorHandle {
auditorPub: string;
}
-// Work in progress for regional currencies
-export interface CurrencySettingsRecord {
- currency: string;
-
- globalScopeExchanges: DbExchangeHandle[];
-
- globalScopeAuditors: DbAuditorHandle[];
-
- // Used to decide which auditor to show the currency under
- // when multiple auditors apply.
- auditorPriority: string[];
-
- // Later, we might add stuff related to how the currency is rendered.
-}
-
export enum RefundGroupStatus {
Pending = 0x0100_0000,
Done = 0x0500_0000,
Failed = 0x0501_0000,
Aborted = 0x0503_0000,
+ Expired = 0x0502_0000,
}
/**
@@ -2294,18 +2360,128 @@ export function passthroughCodec<T>(): Codec<T> {
return codecForAny();
}
+export interface GlobalCurrencyAuditorRecord {
+ id?: number;
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export interface GlobalCurrencyExchangeRecord {
+ id?: number;
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+}
+
+/**
+ * Primary key: transactionItem.transactionId
+ */
+export interface TransactionRecord {
+ /**
+ * Transaction item returned to the client.
+ */
+ transactionItem: Transaction;
+
+ /**
+ * Exchanges involved in the transaction.
+ */
+ exchanges: string[];
+
+ currency: string;
+}
+
+export enum DenomLossStatus {
+ /**
+ * Done indicates that the loss happened.
+ */
+ Done = 0x0500_0000,
+
+ /**
+ * Aborted in the sense that the loss was reversed.
+ */
+ Aborted = 0x0503_0001,
+}
+
+export interface DenomLossEventRecord {
+ denomLossEventId: string;
+ currency: string;
+ denomPubHashes: string[];
+ status: DenomLossStatus;
+ timestampCreated: DbPreciseTimestamp;
+ amount: string;
+ eventType: DenomLossEventType;
+ exchangeBaseUrl: string;
+}
+
/**
* Schema definition for the IndexedDB
* wallet database.
*/
export const WalletStoresV1 = {
- currencySettings: describeStore(
- "currencySettings",
- describeContents<CurrencySettingsRecord>({
- keyPath: ["currency"],
- }),
- {},
- ),
+ denomLossEvents: describeStoreV2({
+ recordCodec: passthroughCodec<DenomLossEventRecord>(),
+ storeName: "denomLossEvents",
+ keyPath: "denomLossEventId",
+ versionAdded: 9,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 9,
+ }),
+ byStatus: describeIndex("byStatus", "status", {
+ versionAdded: 10,
+ }),
+ },
+ }),
+ transactions: describeStoreV2({
+ recordCodec: passthroughCodec<TransactionRecord>(),
+ storeName: "transactions",
+ keyPath: "transactionItem.transactionId",
+ versionAdded: 7,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 7,
+ }),
+ byExchange: describeIndex("byExchange", "exchanges", {
+ versionAdded: 7,
+ multiEntry: true,
+ }),
+ },
+ }),
+ globalCurrencyAuditors: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(),
+ storeName: "globalCurrencyAuditors",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "auditorBaseUrl", "auditorPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
+ globalCurrencyExchanges: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(),
+ storeName: "globalCurrencyExchanges",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "exchangeBaseUrl", "exchangeMasterPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
coinAvailability: describeStore(
"coinAvailability",
describeContents<CoinAvailabilityRecord>({
@@ -2317,6 +2493,9 @@ export const WalletStoresV1 = {
"maxAge",
"freshCoinCount",
]),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 8,
+ }),
},
),
coins: describeStore(
@@ -2379,6 +2558,9 @@ export const WalletStoresV1 = {
autoIncrement: true,
}),
{
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
byPointer: describeIndex(
"byDetailsPointer",
["exchangeBaseUrl", "currency", "masterPublicKey"],
@@ -2406,6 +2588,13 @@ export const WalletStoresV1 = {
}),
{
byStatus: describeIndex("byStatus", "operationStatus"),
+ byOriginatingTransactionId: describeIndex(
+ "byOriginatingTransactionId",
+ "originatingTransactionId",
+ {
+ versionAdded: 5,
+ },
+ ),
},
),
refreshSessions: describeStore(
@@ -2420,7 +2609,11 @@ export const WalletStoresV1 = {
describeContents<RecoupGroupRecord>({
keyPath: "recoupGroupId",
}),
- {},
+ {
+ byStatus: describeIndex("byStatus", "operationStatus", {
+ versionAdded: 6,
+ }),
+ },
),
purchases: describeStore(
"purchases",
@@ -2457,6 +2650,9 @@ export const WalletStoresV1 = {
}),
{
byStatus: describeIndex("byStatus", "status"),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
byTalerWithdrawUri: describeIndex(
"byTalerWithdrawUri",
"wgInfo.bankInfo.talerWithdrawUri",
@@ -2483,7 +2679,9 @@ export const WalletStoresV1 = {
describeContents<BankWithdrawUriRecord>({
keyPath: "talerWithdrawUri",
}),
- {},
+ {
+ byGroup: describeIndex("byGroup", "withdrawalGroupId"),
+ },
),
backupProviders: describeStore(
"backupProviders",
@@ -2631,6 +2829,7 @@ export const WalletStoresV1 = {
"coinPub",
"rtxid",
]),
+ // FIXME: Why is this a list of index keys? Confusing!
byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
},
),
@@ -2643,13 +2842,23 @@ export const WalletStoresV1 = {
),
};
-export type WalletDbReadOnlyTransaction<
- Stores extends StoreNames<typeof WalletStoresV1> & string,
-> = DbReadOnlyTransaction<typeof WalletStoresV1, Stores>;
+export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>;
-export type WalletDbReadWriteTransaction<
- Stores extends StoreNames<typeof WalletStoresV1> & string,
-> = DbReadWriteTransaction<typeof WalletStoresV1, Stores>;
+export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
+
+export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
/**
* An applied migration.
@@ -2859,7 +3068,12 @@ export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> {
export interface FixupDescription {
name: string;
- fn(tx: GetReadWriteAccess<typeof WalletStoresV1>): Promise<void>;
+ fn(
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ Array<StoreNames<typeof WalletStoresV1>>
+ >,
+ ): Promise<void>;
}
/**
@@ -2873,7 +3087,7 @@ export async function applyFixups(
db: DbAccess<typeof WalletStoresV1>,
): Promise<void> {
logger.trace("applying fixups");
- await db.mktxAll().runReadWrite(async (tx) => {
+ await db.runAllStoresReadWriteTx({}, async (tx) => {
for (const fixupInstruction of walletDbFixups) {
logger.trace(`checking fixup ${fixupInstruction.name}`);
const fixupRecord = await tx.fixups.get(fixupInstruction.name);
@@ -2947,8 +3161,10 @@ function upgradeFromStoreMap(
});
} catch (e) {
const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
- throw Error(
+ throw new Error(
`Migration failed. Could not create store ${swi.storeName}.${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
);
}
}
@@ -2975,6 +3191,8 @@ function upgradeFromStoreMap(
const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
throw Error(
`Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
);
}
}
@@ -3076,7 +3294,12 @@ export async function openStoredBackupsDatabase(
onStoredBackupsDbUpgradeNeeded,
);
- const handle = new DbAccess(backupsDbHandle, StoredBackupStores);
+ const handle = new DbAccessImpl(
+ backupsDbHandle,
+ StoredBackupStores,
+ {},
+ CancellationToken.CONTINUE,
+ );
return handle;
}
@@ -3090,7 +3313,7 @@ export async function openStoredBackupsDatabase(
export async function openTalerDatabase(
idbFactory: IDBFactory,
onVersionChange: () => void,
-): Promise<DbAccess<typeof WalletStoresV1>> {
+): Promise<IDBDatabase> {
const metaDbHandle = await openDatabase(
idbFactory,
TALER_WALLET_META_DB_NAME,
@@ -3099,22 +3322,25 @@ export async function openTalerDatabase(
onMetaDbUpgradeNeeded,
);
- const metaDb = new DbAccess(metaDbHandle, walletMetadataStore);
+ const metaDb = new DbAccessImpl(
+ metaDbHandle,
+ walletMetadataStore,
+ {},
+ CancellationToken.CONTINUE,
+ );
let currentMainVersion: string | undefined;
- await metaDb
- .mktx((stores) => [stores.metaConfig])
- .runReadWrite(async (tx) => {
- const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
- if (!dbVersionRecord) {
- currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_WALLET_MAIN_DB_NAME,
- });
- } else {
- currentMainVersion = dbVersionRecord.value;
- }
- });
+ await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => {
+ const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
+ if (!dbVersionRecord) {
+ currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ } else {
+ currentMainVersion = dbVersionRecord.value;
+ }
+ });
if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) {
switch (currentMainVersion) {
@@ -3128,14 +3354,15 @@ export async function openTalerDatabase(
case "taler-wallet-main-v9":
// We consider this a pre-release
// development version, no migration is done.
- await metaDb
- .mktx((stores) => [stores.metaConfig])
- .runReadWrite(async (tx) => {
+ await metaDb.runReadWriteTx(
+ { storeNames: ["metaConfig"] },
+ async (tx) => {
await tx.metaConfig.put({
key: CURRENT_DB_CONFIG_KEY,
value: TALER_WALLET_MAIN_DB_NAME,
});
- });
+ },
+ );
break;
default:
throw Error(
@@ -3152,11 +3379,15 @@ export async function openTalerDatabase(
onTalerDbUpgradeNeeded,
);
- const handle = new DbAccess(mainDbHandle, WalletStoresV1);
-
- await applyFixups(handle);
+ const mainDbAccess = new DbAccessImpl(
+ mainDbHandle,
+ WalletStoresV1,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ await applyFixups(mainDbAccess);
- return handle;
+ return mainDbHandle;
}
export async function deleteTalerDatabase(
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
index e841d1d20..d3085ecb4 100644
--- a/packages/taler-wallet-core/src/dbless.ts
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -29,29 +29,26 @@ import {
AbsoluteTime,
AgeRestriction,
AmountJson,
- Amounts,
AmountString,
+ Amounts,
+ DenominationPubKey,
+ ExchangeBatchDepositRequest,
+ ExchangeBatchWithdrawRequest,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ Logger,
TalerCorebankApiClient,
+ UnblindedSignature,
codecForAny,
codecForBankWithdrawalOperationPostResponse,
codecForBatchDepositSuccess,
codecForExchangeMeltResponse,
codecForExchangeRevealResponse,
- codecForWithdrawResponse,
- DenominationPubKey,
+ codecForExchangeWithdrawBatchResponse,
encodeCrock,
- ExchangeBatchDepositRequest,
- ExchangeMeltRequest,
- ExchangeProtocolVersion,
- ExchangeWithdrawRequest,
getRandomBytes,
hashWire,
- Logger,
parsePaytoUri,
- UnblindedSignature,
- ExchangeBatchWithdrawRequest,
- ExchangeWithdrawBatchResponse,
- codecForExchangeWithdrawBatchResponse,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
@@ -59,16 +56,12 @@ import {
} from "@gnu-taler/taler-util/http";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { DenominationRecord } from "./db.js";
-import {
- ExchangeInfo,
- ExchangeKeysDownloadResult,
- isWithdrawableDenom,
-} from "./index.js";
-import { assembleRefreshRevealRequest } from "./operations/refresh.js";
-import {
- getBankStatusUrl,
- getBankWithdrawalInfo,
-} from "./operations/withdraw.js";
+import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js";
+import { assembleRefreshRevealRequest } from "./refresh.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js";
+
+export { downloadExchangeInfo };
const logger = new Logger("dbless.ts");
@@ -106,13 +99,13 @@ export async function checkReserve(
if (longpollTimeoutMs) {
reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`);
}
- const resp = await http.get(reqUrl.href);
+ const resp = await http.fetch(reqUrl.href, { method: "GET" });
if (resp.status !== 200) {
throw new Error("reserve not okay");
}
}
-export interface TopupReserveWithDemobankArgs {
+export interface TopupReserveWithBankArgs {
http: HttpRequestLibrary;
reservePub: string;
corebankApiBaseUrl: string;
@@ -120,9 +113,7 @@ export interface TopupReserveWithDemobankArgs {
amount: AmountString;
}
-export async function topupReserveWithDemobank(
- args: TopupReserveWithDemobankArgs,
-) {
+export async function topupReserveWithBank(args: TopupReserveWithBankArgs) {
const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args;
const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
const bankUser = await bankClient.createRandomBankUser();
@@ -140,9 +131,12 @@ export async function topupReserveWithDemobank(
if (plainPaytoUris.length <= 0) {
throw new Error();
}
- const httpResp = await http.postJson(bankStatusUrl, {
- reserve_pub: reservePub,
- selected_exchange: plainPaytoUris[0],
+ const httpResp = await http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: {
+ reserve_pub: reservePub,
+ selected_exchange: plainPaytoUris[0],
+ },
});
await readSuccessResponseJsonOrThrow(
httpResp,
@@ -245,7 +239,7 @@ export async function depositCoin(args: {
}): Promise<void> {
const { coin, http, cryptoApi } = args;
const depositPayto =
- args.depositPayto ?? "payto://x-taler-bank/localhost/foo";
+ args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo";
const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16));
const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now());
const contractTermsHash =
@@ -369,7 +363,10 @@ export async function refreshCoin(req: {
oldCoin.exchangeBaseUrl,
);
- const revealResp = await http.postJson(reqUrl.href, revealRequest);
+ const revealResp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: revealRequest,
+ });
logger.info("requesting reveal done");
diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts
new file mode 100644
index 000000000..ecc1fa881
--- /dev/null
+++ b/packages/taler-wallet-core/src/denomSelection.ts
@@ -0,0 +1,199 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Selection of denominations for withdrawals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ DenomSelectionState,
+ ForcedDenomSel,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampAbsoluteFromDb } from "./db.js";
+import { isWithdrawableDenom } from "./denominations.js";
+
+const logger = new Logger("denomSelection.ts");
+
+/**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available
+ * amount, but never larger.
+ */
+export function selectWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ denomselAllowLate: boolean = false,
+): DenomSelectionState {
+ let remaining = Amounts.copy(amountAvailable);
+
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `selecting withdrawal denoms for ${Amounts.stringify(amountAvailable)}`,
+ );
+ }
+
+ for (const d of denoms) {
+ const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount;
+ const res = Amounts.divmod(remaining, cost);
+ const count = res.quotient;
+ remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount;
+ if (count > 0) {
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(d.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: d.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || d.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(d.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `denom_pub_hash=${
+ d.denomPubHash
+ }, count=${count}, val=${Amounts.stringify(
+ d.value,
+ )}, wdfee=${Amounts.stringify(d.fees.feeWithdraw)}`,
+ );
+ }
+
+ if (Amounts.isZero(remaining)) {
+ break;
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace("(end of denom selection)");
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
+
+export function selectForcedWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ forcedDenomSel: ForcedDenomSel,
+ denomselAllowLate: boolean,
+): DenomSelectionState {
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ for (const fds of forcedDenomSel.denoms) {
+ const count = fds.count;
+ const denom = denoms.find((x) => {
+ return Amounts.cmp(x.value, fds.value) == 0;
+ });
+ if (!denom) {
+ throw Error(
+ `unable to find denom for forced selection (value ${fds.value})`,
+ );
+ }
+ const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount;
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(denom.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: denom.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
diff --git a/packages/taler-wallet-core/src/util/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts
index 98af5d1a4..98af5d1a4 100644
--- a/packages/taler-wallet-core/src/util/denominations.test.ts
+++ b/packages/taler-wallet-core/src/denominations.test.ts
diff --git a/packages/taler-wallet-core/src/util/denominations.ts b/packages/taler-wallet-core/src/denominations.ts
index db6e69956..d41307d5d 100644
--- a/packages/taler-wallet-core/src/util/denominations.ts
+++ b/packages/taler-wallet-core/src/denominations.ts
@@ -14,6 +14,9 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+/**
+ * Imports.
+ */
import {
AbsoluteTime,
AmountJson,
@@ -21,14 +24,12 @@ import {
AmountString,
DenominationInfo,
Duration,
- durationFromSpec,
FeeDescription,
FeeDescriptionPair,
TalerProtocolTimestamp,
TimePoint,
} from "@gnu-taler/taler-util";
-import { DenominationRecord } from "../db.js";
-import { timestampProtocolFromDb } from "../index.js";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
/**
* Given a list of denominations with the same value and same period of time:
@@ -469,10 +470,10 @@ export function isWithdrawableDenom(
} else {
lastPossibleWithdraw = AbsoluteTime.subtractDuraction(
withdrawExpire,
- durationFromSpec({ minutes: 5 }),
+ Duration.fromSpec({ minutes: 5 }),
);
}
const remaining = Duration.getRemaining(lastPossibleWithdraw, now);
const stillOkay = remaining.d_ms !== 0;
- return started && stillOkay && !d.isRevoked && d.isOffered;
+ return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost;
}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
index 8205b7583..c4cd98d73 100644
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -15,6 +15,10 @@
*/
/**
+ * Implementation of the deposit transaction.
+ */
+
+/**
* Imports.
*/
import {
@@ -29,32 +33,36 @@ import {
DepositGroupFees,
Duration,
ExchangeBatchDepositRequest,
+ ExchangeHandle,
ExchangeRefundRequest,
HttpStatusCode,
Logger,
MerchantContractTerms,
NotificationType,
- PayCoinSelection,
PrepareDepositRequest,
PrepareDepositResponse,
RefreshReason,
+ SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TrackTransaction,
TransactionAction,
+ TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
URL,
WireFee,
+ assertUnreachable,
canonicalJson,
+ checkDbInvariant,
+ checkLogicInvariant,
codecForBatchDepositSuccess,
codecForTackTransactionAccepted,
codecForTackTransactionWired,
- durationFromSpec,
encodeCrock,
getRandomBytes,
hashTruncate32,
@@ -64,49 +72,248 @@ import {
stringToBytes,
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import { DepositElementStatus, DepositGroupRecord } from "../db.js";
+import { selectPayCoins } from "./coinSelection.js";
import {
- DepositOperationStatus,
- DepositTrackingInfo,
- KycPendingInfo,
PendingTaskType,
- RefreshOperationStatus,
- createRefreshGroup,
- getCandidateWithdrawalDenomsTx,
- getTotalRefreshCost,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
+ TaskIdStr,
TaskRunResult,
TombstoneTag,
+ TransactionContext,
constructTaskIdentifier,
- runLongpollAsync,
spendCoins,
} from "./common.js";
-import { getExchangeDetails } from "./exchanges.js";
+import {
+ DepositElementStatus,
+ DepositGroupRecord,
+ DepositInfoPerExchange,
+ DepositOperationStatus,
+ DepositTrackingInfo,
+ KycPendingInfo,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
import {
extractContractData,
generateDepositPermissions,
getTotalPaymentCost,
} from "./pay-merchant.js";
import {
+ CreateRefreshGroupResult,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
constructTransactionIdentifier,
notifyTransition,
parseTransactionIdentifier,
- stopLongpolling,
} from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
/**
* Logger.
*/
const logger = new Logger("deposits.ts");
+export class DepositTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public depositGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const depositGroupId = this.depositGroupId;
+ const ws = this.wex;
+ // FIXME: We should check first if we are in a final state
+ // where deletion is allowed.
+ await ws.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "tombstones"] },
+ async (tx) => {
+ const tipRecord = await tx.depositGroups.get(depositGroupId);
+ if (tipRecord) {
+ await tx.depositGroups.delete(depositGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
+ });
+ }
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingDeposit:
+ newOpStatus = DepositOperationStatus.SuspendedDeposit;
+ break;
+ case DepositOperationStatus.PendingKyc:
+ newOpStatus = DepositOperationStatus.SuspendedKyc;
+ break;
+ case DepositOperationStatus.PendingTrack:
+ newOpStatus = DepositOperationStatus.SuspendedTrack;
+ break;
+ case DepositOperationStatus.Aborting:
+ newOpStatus = DepositOperationStatus.SuspendedAborting;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return undefined;
+ case DepositOperationStatus.PendingDeposit:
+ case DepositOperationStatus.SuspendedDeposit: {
+ dg.operationStatus = DepositOperationStatus.Aborting;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedDeposit:
+ newOpStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ case DepositOperationStatus.SuspendedAborting:
+ newOpStatus = DepositOperationStatus.Aborting;
+ break;
+ case DepositOperationStatus.SuspendedKyc:
+ newOpStatus = DepositOperationStatus.PendingKyc;
+ break;
+ case DepositOperationStatus.SuspendedTrack:
+ newOpStatus = DepositOperationStatus.PendingTrack;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.Aborting: {
+ dg.operationStatus = DepositOperationStatus.Failed;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+}
+
/**
* Get the (DD37-style) transaction status based on the
* database record of a deposit group.
@@ -204,303 +411,45 @@ export function computeDepositTransactionActions(
}
}
-/**
- * Put a deposit group in a suspended state.
- * While the deposit group is suspended, no network requests
- * will be made to advance the transaction status.
- */
-export async function suspendDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.PendingDeposit:
- newOpStatus = DepositOperationStatus.SuspendedDeposit;
- break;
- case DepositOperationStatus.PendingKyc:
- newOpStatus = DepositOperationStatus.SuspendedKyc;
- break;
- case DepositOperationStatus.PendingTrack:
- newOpStatus = DepositOperationStatus.SuspendedTrack;
- break;
- case DepositOperationStatus.Aborting:
- newOpStatus = DepositOperationStatus.SuspendedAborting;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- });
- stopLongpolling(ws, retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumeDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedDeposit:
- newOpStatus = DepositOperationStatus.PendingDeposit;
- break;
- case DepositOperationStatus.SuspendedAborting:
- newOpStatus = DepositOperationStatus.Aborting;
- break;
- case DepositOperationStatus.SuspendedKyc:
- newOpStatus = DepositOperationStatus.PendingKyc;
- break;
- case DepositOperationStatus.SuspendedTrack:
- newOpStatus = DepositOperationStatus.PendingTrack;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return undefined;
- case DepositOperationStatus.PendingDeposit: {
- dg.operationStatus = DepositOperationStatus.Aborting;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- case DepositOperationStatus.SuspendedDeposit:
- // FIXME: Can we abort a suspended transaction?!
- return undefined;
- }
- return undefined;
- });
- stopLongpolling(ws, retryTag);
- // Need to process the operation again.
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failDepositTransaction(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedAborting:
- case DepositOperationStatus.Aborting: {
- dg.operationStatus = DepositOperationStatus.Failed;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- }
- return undefined;
- });
- // FIXME: Also cancel ongoing work (via cancellation token, once implemented)
- stopLongpolling(ws, retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function deleteDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
-) {
- // FIXME: We should check first if we are in a final state
- // where deletion is allowed.
- await ws.db
- .mktx((x) => [x.depositGroups, x.tombstones])
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
-}
-
-/**
- * Check whether the refresh associated with the
- * aborting deposit group is done.
- *
- * If done, mark the deposit transaction as aborted.
- *
- * Otherwise continue waiting.
- *
- * FIXME: Wait for the refresh group notifications instead of periodically
- * checking the refresh group status.
- * FIXME: This is just one transaction, can't we do this in the initial
- * transaction of processDepositGroup?
- */
-async function waitForRefreshOnDepositGroup(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: depositGroup.depositGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups, x.depositGroups])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: DepositOperationStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into aborted.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = DepositOperationStatus.Aborted;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = DepositOperationStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = DepositOperationStatus.Aborted;
- }
- }
- if (newOpState) {
- const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
- if (!newDg) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = newOpState;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.pending();
-}
-
async function refundDepositGroup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
- const newTxPerCoin = [...depositGroup.statusPerCoin];
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
+ const newTxPerCoin = [...statusPerCoin];
logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const st = depositGroup.statusPerCoin[i];
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const st = statusPerCoin[i];
switch (st) {
case DepositElementStatus.RefundFailed:
case DepositElementStatus.RefundSuccess:
break;
default: {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
- const coinExchange = await ws.db
- .mktx((x) => [x.coins])
- .runReadOnly(async (tx) => {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coinExchange = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins"] },
+ async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
checkDbInvariant(!!coinRecord);
return coinRecord.exchangeBaseUrl;
- });
- const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
+ },
+ );
+ const refundAmount = payCoinSelection.coinContributions[i];
// We use a constant refund transaction ID, since there can
// only be one refund.
const rtid = 1;
- const sig = await ws.cryptoApi.signRefund({
+ const sig = await wex.cryptoApi.signRefund({
coinPub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
@@ -516,9 +465,10 @@ async function refundDepositGroup(
rtransaction_id: rtid,
};
const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
- const httpResp = await ws.http.fetch(refundUrl.href, {
+ const httpResp = await wex.http.fetch(refundUrl.href, {
method: "POST",
body: refundReq,
+ cancellationToken: wex.cancellationToken,
});
logger.info(
`coin ${i} refund HTTP status for coin: ${httpResp.status}`,
@@ -549,15 +499,18 @@ async function refundDepositGroup(
const currency = Amounts.currencyOf(depositGroup.totalPayCost);
- await ws.db
- .mktx((x) => [
- x.depositGroups,
- x.refreshGroups,
- x.coins,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadWrite(async (tx) => {
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
return;
@@ -566,42 +519,120 @@ async function refundDepositGroup(
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < newTxPerCoin.length; i++) {
refreshCoins.push({
- amount: depositGroup.payCoinSelection.coinContributions[i],
- coinPub: depositGroup.payCoinSelection.coinPubs[i],
+ amount: payCoinSelection.coinContributions[i],
+ coinPub: payCoinSelection.coinPubs[i],
});
}
+ let refreshRes: CreateRefreshGroupResult | undefined = undefined;
if (isDone) {
- const rgid = await createRefreshGroup(
- ws,
+ refreshRes = await createRefreshGroup(
+ wex,
tx,
currency,
refreshCoins,
RefreshReason.AbortDeposit,
+ constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: newDg.depositGroupId,
+ }),
);
- newDg.abortRefreshGroupId = rgid.refreshGroupId;
+ newDg.abortRefreshGroupId = refreshRes.refreshGroupId;
}
await tx.depositGroups.put(newDg);
- });
+ return { refreshRes };
+ },
+ );
+
+ if (res?.refreshRes) {
+ for (const notif of res.refreshRes.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Check whether the refresh associated with the
+ * aborting deposit group is done.
+ *
+ * If done, mark the deposit transaction as aborted.
+ *
+ * Otherwise continue waiting.
+ *
+ * FIXME: Wait for the refresh group notifications instead of periodically
+ * checking the refresh group status.
+ * FIXME: This is just one transaction, can't we do this in the initial
+ * transaction of processDepositGroup?
+ */
+async function waitForRefreshOnDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroup.depositGroupId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: DepositOperationStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into aborted.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = DepositOperationStatus.Aborted;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = DepositOperationStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = DepositOperationStatus.Aborted;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = newOpState;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ return TaskRunResult.backoff();
}
async function processDepositGroupAborting(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
logger.info("processing deposit tx in 'aborting'");
const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
if (!abortRefreshGroupId) {
logger.info("refunding deposit group");
- return refundDepositGroup(ws, depositGroup);
+ return refundDepositGroup(wex, depositGroup);
}
logger.info("waiting for refresh");
- return waitForRefreshOnDepositGroup(ws, depositGroup);
+ return waitForRefreshOnDepositGroup(wex, depositGroup);
}
async function processDepositGroupPendingKyc(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise<TaskRunResult> {
const { depositGroupId } = depositGroup;
@@ -609,10 +640,6 @@ async function processDepositGroupPendingKyc(
tag: TransactionType.Deposit,
depositGroupId,
});
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
const kycInfo = depositGroup.kycInfo;
const userType = "individual";
@@ -621,51 +648,46 @@ async function processDepositGroupPendingKyc(
throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
}
- runLongpollAsync(ws, retryTag, async (ct) => {
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- kycInfo.exchangeBaseUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken: ct,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
- const newDg = await tx.depositGroups.get(depositGroupId);
- if (!newDg) {
- return;
- }
- if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = DepositOperationStatus.PendingTrack;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return { ready: true };
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- return { ready: false };
- } else {
- throw Error(
- `unexpected response from kyc-check (${kycStatusRes.status})`,
- );
- }
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ kycInfo.exchangeBaseUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
});
- return TaskRunResult.longpoll();
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = DepositOperationStatus.PendingTrack;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
}
/**
@@ -674,7 +696,7 @@ async function processDepositGroupPendingKyc(
* and transition the transaction to the KYC required state.
*/
async function transitionToKycRequired(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
kycInfo: KycPendingInfo,
exchangeUrl: string,
@@ -692,18 +714,18 @@ async function transitionToKycRequired(
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
- const kycStatusReq = await ws.http.fetch(url.href, {
+ const kycStatusReq = await wex.http.fetch(url.href, {
method: "GET",
});
if (kycStatusReq.status === HttpStatusCode.Ok) {
logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.finished();
+ return TaskRunResult.backoff();
} else if (kycStatusReq.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusReq.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
@@ -721,8 +743,9 @@ async function transitionToKycRequired(
await tx.depositGroups.put(dg);
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.finished();
} else {
throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
@@ -730,21 +753,33 @@ async function transitionToKycRequired(
}
async function processDepositGroupPendingTrack(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
- cancellationToken?: CancellationToken,
): Promise<TaskRunResult> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
const { depositGroupId } = depositGroup;
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
// FIXME: Make the URL part of the coin selection?
- const exchangeBaseUrl = await ws.db
- .mktx((x) => [x.coins])
- .runReadWrite(async (tx) => {
+ const exchangeBaseUrl = await wex.db.runReadWriteTx(
+ { storeNames: ["coins"] },
+ async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
+ checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
return coinRecord.exchangeBaseUrl;
- });
+ },
+ );
let updatedTxStatus: DepositElementStatus | undefined = undefined;
let newWiredCoin:
@@ -754,9 +789,9 @@ async function processDepositGroupPendingTrack(
}
| undefined;
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ if (statusPerCoin[i] !== DepositElementStatus.Wired) {
const track = await trackDeposit(
- ws,
+ wex,
depositGroup,
coinPub,
exchangeBaseUrl,
@@ -773,7 +808,7 @@ async function processDepositGroupPendingTrack(
requirementRow,
};
return transitionToKycRequired(
- ws,
+ wex,
depositGroup,
kycInfo,
exchangeBaseUrl,
@@ -790,7 +825,7 @@ async function processDepositGroupPendingTrack(
}
const fee = await getExchangeWireFee(
- ws,
+ wex,
payto.targetType,
exchangeBaseUrl,
track.execution_time,
@@ -814,13 +849,16 @@ async function processDepositGroupPendingTrack(
}
if (updatedTxStatus !== undefined) {
- await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
+ if (!dg.statusPerCoin) {
+ return;
+ }
if (updatedTxStatus !== undefined) {
dg.statusPerCoin[i] = updatedTxStatus;
}
@@ -840,22 +878,26 @@ async function processDepositGroupPendingTrack(
dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
}
await tx.depositGroups.put(dg);
- });
+ },
+ );
}
}
let allWired = true;
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
}
+ if (!dg.statusPerCoin) {
+ return undefined;
+ }
const oldTxState = computeDepositTransactionStatus(dg);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ for (let i = 0; i < dg.statusPerCoin.length; i++) {
+ if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) {
allWired = false;
break;
}
@@ -869,32 +911,37 @@ async function processDepositGroupPendingTrack(
}
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
- });
+ },
+ );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId,
});
- notifyTransition(ws, transactionId, transitionInfo);
+ notifyTransition(wex, transactionId, transitionInfo);
if (allWired) {
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
return TaskRunResult.finished();
} else {
- // FIXME: Use long-polling.
- return TaskRunResult.pending();
+ return TaskRunResult.longpollReturnedPending();
}
}
async function processDepositGroupPendingDeposit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
cancellationToken?: CancellationToken,
): Promise<TaskRunResult> {
logger.info("processing deposit group in pending(deposit)");
const depositGroupId = depositGroup.depositGroupId;
- const contractTermsRec = await ws.db
- .mktx((x) => [x.contractTerms])
- .runReadOnly(async (tx) => {
+ const contractTermsRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
return tx.contractTerms.get(depositGroup.contractTermsHash);
- });
+ },
+ );
if (!contractTermsRec) {
throw Error("contract terms for deposit not found in database");
}
@@ -914,9 +961,91 @@ async function processDepositGroupPendingDeposit(
// Check for cancellation before expensive operations.
cancellationToken?.throwIfCancelled();
+ if (!depositGroup.payCoinSelection) {
+ logger.info("missing coin selection for deposit group, selecting now");
+ // FIXME: Consider doing the coin selection inside the txn
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ switch (payCoinSel.type) {
+ case "success":
+ logger.info("coin selection success");
+ break;
+ case "failure":
+ logger.info("coin selection failure");
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ logger.info("coin selection prospective");
+ throw Error("insufficient balance (waiting on pending refresh)");
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return false;
+ }
+ if (dg.statusPerCoin) {
+ return false;
+ }
+ dg.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ dg.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ await tx.depositGroups.put(dg);
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: dg.payCoinSelection.coinPubs,
+ contributions: dg.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ return true;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
// FIXME: Cache these!
const depositPermissions = await generateDepositPermissions(
- ws,
+ wex,
depositGroup.payCoinSelection,
contractData,
);
@@ -963,8 +1092,9 @@ async function processDepositGroupPendingDeposit(
// Check for cancellation before making network request.
cancellationToken?.throwIfCancelled();
const url = new URL(`batch-deposit`, exchangeUrl);
- logger.info(`depositing to ${url}`);
- const httpResp = await ws.http.fetch(url.href, {
+ logger.info(`depositing to ${url.href}`);
+ logger.trace(`deposit request: ${j2s(batchReq)}`);
+ const httpResp = await wex.http.fetch(url.href, {
method: "POST",
body: batchReq,
cancellationToken: cancellationToken,
@@ -974,13 +1104,16 @@ async function processDepositGroupPendingDeposit(
codecForBatchDepositSuccess(),
);
- await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
+ if (!dg.statusPerCoin) {
+ return;
+ }
for (const batchIndex of batchIndexes) {
const coinStatus = dg.statusPerCoin[batchIndex];
switch (coinStatus) {
@@ -989,12 +1122,13 @@ async function processDepositGroupPendingDeposit(
await tx.depositGroups.put(dg);
}
}
- });
+ },
+ );
}
- const transitionInfo = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
@@ -1004,27 +1138,26 @@ async function processDepositGroupPendingDeposit(
await tx.depositGroups.put(dg);
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
- });
+ },
+ );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
}
/**
* Process a deposit group that is not in its final state yet.
*/
export async function processDepositGroup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroupId: string,
- options: {
- cancellationToken?: CancellationToken;
- } = {},
): Promise<TaskRunResult> {
- const depositGroup = await ws.db
- .mktx((x) => [x.depositGroups])
- .runReadOnly(async (tx) => {
+ const depositGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
return tx.depositGroups.get(depositGroupId);
- });
+ },
+ );
if (!depositGroup) {
logger.warn(`deposit group ${depositGroupId} not found`);
return TaskRunResult.finished();
@@ -1032,35 +1165,30 @@ export async function processDepositGroup(
switch (depositGroup.operationStatus) {
case DepositOperationStatus.PendingTrack:
- return processDepositGroupPendingTrack(
- ws,
- depositGroup,
- options.cancellationToken,
- );
+ return processDepositGroupPendingTrack(wex, depositGroup);
case DepositOperationStatus.PendingKyc:
- return processDepositGroupPendingKyc(ws, depositGroup);
+ return processDepositGroupPendingKyc(wex, depositGroup);
case DepositOperationStatus.PendingDeposit:
- return processDepositGroupPendingDeposit(
- ws,
- depositGroup,
- options.cancellationToken,
- );
+ return processDepositGroupPendingDeposit(wex, depositGroup);
case DepositOperationStatus.Aborting:
- return processDepositGroupAborting(ws, depositGroup);
+ return processDepositGroupAborting(wex, depositGroup);
}
return TaskRunResult.finished();
}
+/**
+ * FIXME: Consider moving this to exchanges.ts.
+ */
async function getExchangeWireFee(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
wireType: string,
baseUrl: string,
time: TalerProtocolTimestamp,
): Promise<WireFee> {
- const exchangeDetails = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
const ex = await tx.exchanges.get(baseUrl);
if (!ex || !ex.detailsPointer) return undefined;
return await tx.exchangeDetails.indexes.byPointer.get([
@@ -1068,7 +1196,8 @@ async function getExchangeWireFee(
ex.detailsPointer.currency,
ex.detailsPointer.masterPublicKey,
]);
- });
+ },
+ );
if (!exchangeDetails) {
throw Error(`exchange missing: ${baseUrl}`);
@@ -1097,7 +1226,7 @@ async function getExchangeWireFee(
}
async function trackDeposit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
coinPub: string,
exchangeUrl: string,
@@ -1111,7 +1240,7 @@ async function trackDeposit(
`deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
exchangeUrl,
);
- const sigResp = await ws.cryptoApi.signTrackTransaction({
+ const sigResp = await wex.cryptoApi.signTrackTransaction({
coinPub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
@@ -1119,7 +1248,11 @@ async function trackDeposit(
wireHash,
});
url.searchParams.set("merchant_sig", sigResp.sig);
- const httpResp = await ws.http.fetch(url.href, { method: "GET" });
+ url.searchParams.set("timeout_ms", "30000");
+ const httpResp = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
logger.trace(`deposits response status: ${httpResp.status}`);
switch (httpResp.status) {
case HttpStatusCode.Accepted: {
@@ -1147,12 +1280,9 @@ async function trackDeposit(
/**
* Check if creating a deposit group is possible and calculate
* the associated fees.
- *
- * FIXME: This should be renamed to checkDepositGroup,
- * as it doesn't prepare anything
*/
-export async function prepareDepositGroup(
- ws: InternalWalletState,
+export async function checkDepositGroup(
+ wex: WalletExecutionContext,
req: PrepareDepositRequest,
): Promise<PrepareDepositResponse> {
const p = parsePaytoUri(req.depositPaytoUri);
@@ -1160,15 +1290,16 @@ export async function prepareDepositGroup(
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
+ const currency = Amounts.currencyOf(amount);
- const exchangeInfos: { url: string; master_pub: string }[] = [];
+ const exchangeInfos: ExchangeHandle[] = [];
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
- const details = await getExchangeDetails(tx, e.baseUrl);
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
if (!details || amount.currency !== details.currency) {
continue;
}
@@ -1177,7 +1308,8 @@ export async function prepareDepositGroup(
url: e.baseUrl,
});
}
- });
+ },
+ );
const now = AbsoluteTime.now();
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
@@ -1185,7 +1317,6 @@ export async function prepareDepositGroup(
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: nowRounded,
merchant_base_url: "",
@@ -1195,7 +1326,7 @@ export async function prepareDepositGroup(
order_id: "",
h_wire: "",
pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
),
merchant: {
name: "(wallet)",
@@ -1204,7 +1335,7 @@ export async function prepareDepositGroup(
refund_deadline: TalerProtocolTimestamp.zero(),
};
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
str: canonicalJson(contractTerms),
});
@@ -1214,39 +1345,50 @@ export async function prepareDepositGroup(
"",
);
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
});
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
+ let selCoins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ selCoins = payCoinSel.result.prospectiveCoins;
+ break;
+ case "success":
+ selCoins = payCoinSel.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
}
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);
const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
- ws,
+ wex,
p.targetType,
- payCoinSel.coinSel,
+ selCoins,
);
const fees = await getTotalFeesForDepositAmount(
- ws,
+ wex,
p.targetType,
amount,
- payCoinSel.coinSel,
+ selCoins,
);
return {
@@ -1265,7 +1407,7 @@ export function generateDepositGroupTxId(): string {
}
export async function createDepositGroup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: CreateDepositGroupRequest,
): Promise<CreateDepositGroupResponse> {
const p = parsePaytoUri(req.depositPaytoUri);
@@ -1274,15 +1416,16 @@ export async function createDepositGroup(
}
const amount = Amounts.parseOrThrow(req.amount);
+ const currency = amount.currency;
const exchangeInfos: { url: string; master_pub: string }[] = [];
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
- const details = await getExchangeDetails(tx, e.baseUrl);
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
if (!details || amount.currency !== details.currency) {
continue;
}
@@ -1291,22 +1434,22 @@ export async function createDepositGroup(
url: e.baseUrl,
});
}
- });
+ },
+ );
const now = AbsoluteTime.now();
const wireDeadline = AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
);
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
- const noncePair = await ws.cryptoApi.createEddsaKeypair({});
- const merchantPair = await ws.cryptoApi.createEddsaKeypair({});
+ const noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ const merchantPair = await wex.cryptoApi.createEddsaKeypair({});
const wireSalt = encodeCrock(getRandomBytes(16));
const wireHash = hashWire(req.depositPaytoUri, wireSalt);
const contractTerms: MerchantContractTerms = {
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: nowRounded,
merchant_base_url: "",
@@ -1316,7 +1459,7 @@ export async function createDepositGroup(
order_id: "",
h_wire: wireHash,
pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
),
merchant: {
name: "(wallet)",
@@ -1325,7 +1468,7 @@ export async function createDepositGroup(
refund_deadline: TalerProtocolTimestamp.zero(),
};
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
str: canonicalJson(contractTerms),
});
@@ -1335,27 +1478,38 @@ export async function createDepositGroup(
"",
);
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
});
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "success":
+ coins = payCoinSel.coinSel.coins;
+ break;
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = payCoinSel.result.prospectiveCoins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
}
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
let depositGroupId: string;
if (req.transactionId) {
@@ -1368,12 +1522,25 @@ export async function createDepositGroup(
depositGroupId = encodeCrock(getRandomBytes(32));
}
- const counterpartyEffectiveDepositAmount =
- await getCounterpartyEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel.coinSel,
+ const infoPerExchange: Record<string, DepositInfoPerExchange> = {};
+
+ for (let i = 0; i < coins.length; i++) {
+ let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl];
+ if (!depPerExchange) {
+ infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = {
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfAmount(totalDepositCost),
+ ),
+ };
+ }
+ const contrib = coins[i].contribution;
+ depPerExchange.amountEffective = Amounts.stringify(
+ Amounts.add(depPerExchange.amountEffective, contrib).amount,
);
+ }
+
+ const counterpartyEffectiveDepositAmount =
+ await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins);
const depositGroup: DepositGroupRecord = {
contractTermsHash,
@@ -1386,11 +1553,9 @@ export async function createDepositGroup(
AbsoluteTime.toPreciseTimestamp(now),
),
timestampFinished: undefined,
- statusPerCoin: payCoinSel.coinSel.coinPubs.map(
- () => DepositElementStatus.DepositPending,
- ),
- payCoinSelection: payCoinSel.coinSel,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
+ statusPerCoin: undefined,
+ payCoinSelection: undefined,
+ payCoinSelectionUid: undefined,
merchantPriv: merchantPair.priv,
merchantPub: merchantPair.pub,
totalPayCost: Amounts.stringify(totalDepositCost),
@@ -1405,41 +1570,57 @@ export async function createDepositGroup(
salt: wireSalt,
},
operationStatus: DepositOperationStatus.PendingDeposit,
+ infoPerExchange,
};
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
+ if (payCoinSel.type === "success") {
+ depositGroup.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ }
- const newTxState = await ws.db
- .mktx((x) => [
- x.depositGroups,
- x.coins,
- x.recoupGroups,
- x.denominations,
- x.refreshGroups,
- x.coinAvailability,
- x.contractTerms,
- ])
- .runReadWrite(async (tx) => {
- await spendCoins(ws, tx, {
- allocationId: transactionId,
- coinPubs: payCoinSel.coinSel.coinPubs,
- contributions: payCoinSel.coinSel.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayDeposit,
- });
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
+ const transactionId = ctx.transactionId;
+
+ const newTxState = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "recoupGroups",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ "contractTerms",
+ ],
+ },
+ async (tx) => {
+ if (depositGroup.payCoinSelection) {
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: depositGroup.payCoinSelection.coinPubs,
+ contributions: depositGroup.payCoinSelection.coinContributions.map(
+ (x) => Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ }
await tx.depositGroups.put(depositGroup);
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: contractTermsHash,
});
return computeDepositTransactionStatus(depositGroup);
- });
+ },
+ );
- ws.notify({
+ wex.ws.notify({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: {
@@ -1448,11 +1629,13 @@ export async function createDepositGroup(
newTxState,
});
- ws.notify({
+ wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
return {
depositGroupId,
transactionId,
@@ -1464,38 +1647,37 @@ export async function createDepositGroup(
* account after depositing, not considering aggregation.
*/
export async function getCounterpartyEffectiveDepositAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
wireType: string,
- pcs: PayCoinSelection,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
const amt: AmountJson[] = [];
const fees: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
- await ws.db
- .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] },
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
- amt.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
+ amt.push(Amounts.parseOrThrow(pcs[i].contribution));
fees.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
}
for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
if (!exchangeDetails) {
continue;
}
@@ -1514,7 +1696,8 @@ export async function getCounterpartyEffectiveDepositAmount(
fees.push(Amounts.parseOrThrow(fee));
}
}
- });
+ },
+ );
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}
@@ -1523,58 +1706,46 @@ export async function getCounterpartyEffectiveDepositAmount(
* specified amount using the selected coins and the wire method.
*/
async function getTotalFeesForDepositAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
wireType: string,
total: AmountJson,
- pcs: PayCoinSelection,
+ pcs: SelectedProspectiveCoin[],
): Promise<DepositGroupFees> {
const wireFee: AmountJson[] = [];
const coinFee: AmountJson[] = [];
const refreshFee: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
- const currency = Amounts.currencyOf(total);
-
- await ws.db
- .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
-
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
denom,
amountLeft,
- ws.config.testing.denomselAllowLate,
);
refreshFee.push(refreshCost);
}
for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
if (!exchangeDetails) {
continue;
}
@@ -1591,7 +1762,8 @@ async function getTotalFeesForDepositAmount(
wireFee.push(Amounts.parseOrThrow(fee));
}
}
- });
+ },
+ );
return {
coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
index 176ed09d9..5cb9400be 100644
--- a/packages/taler-wallet-core/src/dev-experiments.ts
+++ b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -25,14 +25,29 @@
* Imports.
*/
-import { Logger, parseDevExperimentUri } from "@gnu-taler/taler-util";
-import { ConfigRecordKey } from "./db.js";
-import { InternalWalletState } from "./internal-wallet-state.js";
+import {
+ DenomLossEventType,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ encodeCrock,
+ getRandomBytes,
+ parseDevExperimentUri,
+} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
HttpRequestOptions,
HttpResponse,
} from "@gnu-taler/taler-util/http";
+import { PendingTaskType, constructTaskIdentifier } from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+} from "./db.js";
+import { WalletExecutionContext } from "./wallet.js";
const logger = new Logger("dev-experiments.ts");
@@ -40,7 +55,7 @@ const logger = new Logger("dev-experiments.ts");
* Apply a dev experiment to the wallet database / state.
*/
export async function applyDevExperiment(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
uri: string,
): Promise<void> {
logger.info(`applying dev experiment ${uri}`);
@@ -49,11 +64,74 @@ export async function applyDevExperiment(
logger.info("unable to parse dev experiment URI");
return;
}
- if (!ws.config.testing.devModeActive) {
- throw Error(
- "can't handle devmode URI (other than enable-devmode) unless devmode is active",
- );
+ if (!wex.ws.config.testing.devModeActive) {
+ throw Error("can't handle devmode URI unless devmode is active");
}
+
+ switch (parsedUri.devExperimentId) {
+ case "start-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = true;
+ return;
+ }
+ case "stop-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = false;
+ return;
+ }
+ case "insert-pending-refresh": {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups"] },
+ async (tx) => {
+ const newRg: RefreshGroupRecord = {
+ currency: "TESTKUDOS",
+ expectedOutputPerCoin: [],
+ inputPerCoin: [],
+ oldCoinPubs: [],
+ operationStatus: RefreshOperationStatus.Pending,
+ reason: RefreshReason.Manual,
+ refreshGroupId,
+ statusPerCoin: [],
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ timestampFinished: undefined,
+ originatingTransactionId: undefined,
+ infoPerExchange: {},
+ };
+ await tx.refreshGroups.put(newRg);
+ },
+ );
+ wex.taskScheduler.startShepherdTask(
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ }),
+ );
+ return;
+ }
+ case "insert-denom-loss": {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const eventId = encodeCrock(getRandomBytes(32));
+ const newRg: DenomLossEventRecord = {
+ amount: "TESTKUDOS:42",
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ denomLossEventId: eventId,
+ denomPubHashes: [
+ encodeCrock(getRandomBytes(64)),
+ encodeCrock(getRandomBytes(64)),
+ ],
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.denomLossEvents.put(newRg);
+ },
+ );
+ return;
+ }
+ }
+
throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`);
}
@@ -65,23 +143,6 @@ export class DevExperimentHttpLib implements HttpRequestLibrary {
this.underlyingLib = lib;
}
- get(
- url: string,
- opt?: HttpRequestOptions | undefined,
- ): Promise<HttpResponse> {
- logger.trace(`devexperiment httplib ${url}`);
- return this.underlyingLib.fetch(url, opt);
- }
-
- postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions | undefined,
- ): Promise<HttpResponse> {
- logger.trace(`devexperiment httplib ${url}`);
- return this.underlyingLib.postJson(url, body, opt);
- }
-
fetch(
url: string,
opt?: HttpRequestOptions | undefined,
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
new file mode 100644
index 000000000..d8063d561
--- /dev/null
+++ b/packages/taler-wallet-core/src/exchanges.ts
@@ -0,0 +1,2581 @@
+/*
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ Amount,
+ Amounts,
+ AsyncFlag,
+ CancellationToken,
+ CoinRefreshRequest,
+ CoinStatus,
+ DeleteExchangeRequest,
+ DenomKeyType,
+ DenomLossEventType,
+ DenomOperationMap,
+ DenominationInfo,
+ DenominationPubKey,
+ Duration,
+ EddsaPublicKeyString,
+ ExchangeAuditor,
+ ExchangeDetailedResponse,
+ ExchangeGlobalFees,
+ ExchangeListItem,
+ ExchangeSignKeyJson,
+ ExchangeTosStatus,
+ ExchangeWireAccount,
+ ExchangesListResponse,
+ FeeDescription,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeResourcesResponse,
+ GetExchangeTosResult,
+ GlobalFees,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ OperationErrorInfo,
+ Recoup,
+ RefreshReason,
+ ScopeInfo,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+ WireFee,
+ WireFeeMap,
+ WireFeesJson,
+ WireInfo,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForExchangeKeysJson,
+ durationMul,
+ encodeCrock,
+ getRandomBytes,
+ hashDenomPub,
+ j2s,
+ makeErrorDetail,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ getExpiry,
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ computeDbBackoff,
+ constructTaskIdentifier,
+ getAutoRefreshExecuteThreshold,
+ getExchangeEntryStatusFromRecord,
+ getExchangeState,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ ExchangeDetailsRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+ timestampAbsoluteFromDb,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ createTimeline,
+ isWithdrawableDenom,
+ selectBestForOverlappingDenominations,
+ selectMinimumFee,
+} from "./denominations.js";
+import { DbReadOnlyTransaction } from "./query.js";
+import { createRecoupGroup } from "./recoup.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
+import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("exchanges.ts");
+
+function getExchangeRequestTimeout(): Duration {
+ return Duration.fromSpec({
+ seconds: 15,
+ });
+}
+
+interface ExchangeTosDownloadResult {
+ tosText: string;
+ tosEtag: string;
+ tosContentType: string;
+ tosContentLanguage: string | undefined;
+ tosAvailableLanguages: string[];
+}
+
+async function downloadExchangeWithTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ acceptFormat: string,
+ acceptLanguage: string | undefined,
+): Promise<ExchangeTosDownloadResult> {
+ logger.trace(`downloading exchange tos (type ${acceptFormat})`);
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ const headers: {
+ Accept: string;
+ "Accept-Language"?: string;
+ } = {
+ Accept: acceptFormat,
+ };
+
+ if (acceptLanguage) {
+ headers["Accept-Language"] = acceptLanguage;
+ }
+
+ const resp = await http.fetch(reqUrl.href, {
+ headers,
+ timeout,
+ cancellationToken: wex.cancellationToken,
+ });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || "unknown";
+ const tosContentLanguage = resp.headers.get("content-language") || undefined;
+ const tosContentType = resp.headers.get("content-type") || "text/plain";
+ const availLangStr = resp.headers.get("avail-languages") || "";
+ // Work around exchange bug that reports the same language multiple times.
+ const availLangSet = new Set<string>(
+ availLangStr.split(",").map((x) => x.trim()),
+ );
+ const tosAvailableLanguages = [...availLangSet];
+
+ return {
+ tosText,
+ tosEtag,
+ tosContentType,
+ tosContentLanguage,
+ tosAvailableLanguages,
+ };
+}
+
+/**
+ * Get exchange details from the database.
+ */
+async function getExchangeRecordsInternal(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeDetailsRecord | undefined> {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`no exchange found for ${exchangeBaseUrl}`);
+ return;
+ }
+ const dp = r.detailsPointer;
+ if (!dp) {
+ logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`);
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ const details = await tx.exchangeDetails.indexes.byPointer.get([
+ r.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+ if (!details) {
+ logger.warn(
+ `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
+ );
+ }
+ return details;
+}
+
+export async function getExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<ScopeInfo> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return {
+ type: ScopeType.Exchange,
+ currency: currency,
+ url: exchangeBaseUrl,
+ };
+ }
+ return internalGetExchangeScopeInfo(tx, det);
+}
+
+async function internalGetExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ exchangeDetails: ExchangeDetailsRecord,
+): Promise<ScopeInfo> {
+ const globalExchangeRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ exchangeDetails.exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ if (globalExchangeRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Global,
+ };
+ } else {
+ for (const aud of exchangeDetails.auditors) {
+ const globalAuditorRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ aud.auditor_url,
+ aud.auditor_pub,
+ ]);
+ if (globalAuditorRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Auditor,
+ url: aud.auditor_url,
+ };
+ }
+ }
+ }
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Exchange,
+ url: exchangeDetails.exchangeBaseUrl,
+ };
+}
+
+async function makeExchangeListItem(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ r: ExchangeEntryRecord,
+ exchangeDetails: ExchangeDetailsRecord | undefined,
+ lastError: TalerErrorDetail | undefined,
+): Promise<ExchangeListItem> {
+ const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+ ? {
+ error: lastError,
+ }
+ : undefined;
+
+ let scopeInfo: ScopeInfo | undefined = undefined;
+
+ if (exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+
+ return {
+ exchangeBaseUrl: r.baseUrl,
+ masterPub: exchangeDetails?.masterPublicKey,
+ noFees: r.noFees ?? false,
+ peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
+ currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ ageRestrictionOptions: exchangeDetails?.ageMask
+ ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
+ : [],
+ paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
+ lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
+ lastUpdateErrorInfo,
+ scopeInfo: scopeInfo ?? {
+ type: ScopeType.Exchange,
+ currency: "UNKNOWN",
+ url: r.baseUrl,
+ },
+ };
+}
+
+export interface ExchangeWireDetails {
+ currency: string;
+ masterPublicKey: EddsaPublicKeyString;
+ wireInfo: WireInfo;
+ exchangeBaseUrl: string;
+ auditors: ExchangeAuditor[];
+ globalFees: ExchangeGlobalFees[];
+}
+
+export async function getExchangeWireDetailsInTx(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeWireDetails | undefined> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return undefined;
+ }
+ return {
+ currency: det.currency,
+ masterPublicKey: det.masterPublicKey,
+ wireInfo: det.wireInfo,
+ exchangeBaseUrl: det.exchangeBaseUrl,
+ auditors: det.auditors,
+ globalFees: det.globalFees,
+ };
+}
+
+export async function lookupExchangeByUri(
+ wex: WalletExecutionContext,
+ req: GetExchangeEntryByUrlRequest,
+): Promise<ExchangeListItem> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeRec.baseUrl,
+ );
+ const opRetryRecord = await tx.operationRetries.get(
+ TaskIdentifiers.forExchangeUpdate(exchangeRec),
+ );
+ return await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ );
+ },
+ );
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function acceptExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch && exch.tosCurrentEtag) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = exch.tosCurrentEtag;
+ exch.tosAcceptedTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function forgetExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = undefined;
+ exch.tosAcceptedTimestamp = undefined;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Validate wire fees and wire accounts.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateWireInfo(
+ wex: WalletExecutionContext,
+ versionCurrent: number,
+ wireInfo: ExchangeKeysDownloadResult,
+ masterPublicKey: string,
+): Promise<WireInfo> {
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({
+ masterPub: masterPublicKey,
+ paytoUri: a.payto_uri,
+ sig: a.master_sig,
+ versionCurrent,
+ conversionUrl: a.conversion_url,
+ creditRestrictions: a.credit_restrictions,
+ debitRestrictions: a.debit_restrictions,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ logger.trace("account validation done");
+ const feesForType: WireFeeMap = {};
+ for (const wireMethod of Object.keys(wireInfo.wireFees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.wireFees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.stringify(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.stringify(x.wire_fee),
+ };
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
+ masterPub: masterPublicKey,
+ type: wireMethod,
+ wf: fee,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ return {
+ accounts: wireInfo.accounts,
+ feesForType,
+ };
+}
+
+/**
+ * Validate global fees.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateGlobalFees(
+ wex: WalletExecutionContext,
+ fees: GlobalFees[],
+ masterPub: string,
+): Promise<ExchangeGlobalFees[]> {
+ const egf: ExchangeGlobalFees[] = [];
+ for (const gf of fees) {
+ logger.trace("validating exchange global fees");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
+ masterPub,
+ gf,
+ });
+ isValid = v;
+ }
+
+ if (!isValid) {
+ throw Error("exchange global fees signature invalid: " + gf.master_sig);
+ }
+ egf.push({
+ accountFee: Amounts.stringify(gf.account_fee),
+ historyFee: Amounts.stringify(gf.history_fee),
+ purseFee: Amounts.stringify(gf.purse_fee),
+ startDate: gf.start_date,
+ endDate: gf.end_date,
+ signature: gf.master_sig,
+ historyTimeout: gf.history_expiration,
+ purseLimit: gf.purse_account_limit,
+ purseTimeout: gf.purse_timeout,
+ });
+ }
+
+ return egf;
+}
+
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
+export async function addPresetExchangeEntry(
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+ currencyHint?: string,
+): Promise<{ notification?: WalletNotification }> {
+ let exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ baseUrl: exchangeBaseUrl,
+ presetCurrencyHint: currencyHint,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ lastKeysEtag: undefined,
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ return {
+ notification: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: exchangeBaseUrl,
+ // Exchange did not exist yet
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ },
+ };
+ }
+ return {};
+}
+
+async function provideExchangeRecordInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
+ baseUrl: string,
+): Promise<{
+ exchange: ExchangeEntryRecord;
+ exchangeDetails: ExchangeDetailsRecord | undefined;
+ notification?: WalletNotification;
+}> {
+ let notification: WalletNotification | undefined = undefined;
+ let exchange = await tx.exchanges.get(baseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
+ baseUrl: baseUrl,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ // The first update should always be done in a way that ignores the cache,
+ // so that removing and re-adding an exchange works properly, even
+ // if /keys is cached in the browser.
+ cachebreakNextUpdate: true,
+ lastKeysEtag: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ exchange = r;
+ notification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: r.baseUrl,
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
+ return { exchange, exchangeDetails, notification };
+}
+
+export interface ExchangeKeysDownloadResult {
+ baseUrl: string;
+ masterPublicKey: string;
+ currency: string;
+ auditors: ExchangeAuditor[];
+ currentDenominations: DenominationRecord[];
+ protocolVersion: string;
+ signingKeys: ExchangeSignKeyJson[];
+ reserveClosingDelay: TalerProtocolDuration;
+ expiry: TalerProtocolTimestamp;
+ recoup: Recoup[];
+ listIssueDate: TalerProtocolTimestamp;
+ globalFees: GlobalFees[];
+ accounts: ExchangeWireAccount[];
+ wireFees: { [methodName: string]: WireFeesJson[] };
+}
+
+/**
+ * Download and validate an exchange's /keys data.
+ */
+async function downloadExchangeKeysInfo(
+ baseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ cancellationToken: CancellationToken,
+ noCache: boolean,
+): Promise<ExchangeKeysDownloadResult> {
+ const keysUrl = new URL("keys", baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (noCache) {
+ headers["cache-control"] = "no-cache";
+ }
+ const resp = await http.fetch(keysUrl.href, {
+ timeout,
+ cancellationToken,
+ headers,
+ });
+
+ logger.info("got response to /keys request");
+
+ // We must make sure to parse out the protocol version
+ // before we validate the body.
+ // Otherwise the parser might complain with a hard to understand
+ // message about some other field, when it is just a version
+ // incompatibility.
+
+ const keysJson = await resp.json();
+
+ const protocolVersion = keysJson.version;
+ if (typeof protocolVersion !== "string") {
+ throw Error("bad exchange, does not even specify protocol version");
+ }
+
+ const versionRes = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ protocolVersion,
+ );
+ if (!versionRes) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: resp.requestUrl,
+ httpStatusCode: resp.status,
+ requestMethod: resp.requestMethod,
+ },
+ "exchange protocol version malformed",
+ );
+ }
+ if (!versionRes.compatible) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ "exchange protocol version not compatible with wallet",
+ );
+ }
+
+ const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ "exchange doesn't offer any denominations",
+ );
+ }
+
+ const currency = exchangeKeysJsonUnchecked.currency;
+
+ const currentDenominations: DenominationRecord[] = [];
+
+ for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ const denomPubHash = encodeCrock(hashDenomPub(denomPub));
+ const value = Amounts.parseOrThrow(denomGroup.value);
+ const rec: DenominationRecord = {
+ denomPub,
+ denomPubHash,
+ exchangeBaseUrl: baseUrl,
+ exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
+ isOffered: true,
+ isRevoked: false,
+ isLost: denomIn.lost ?? false,
+ value: Amounts.stringify(value),
+ currency: value.currency,
+ stampExpireDeposit: timestampProtocolToDb(
+ denomIn.stamp_expire_deposit,
+ ),
+ stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: timestampProtocolToDb(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: timestampProtocolToDb(denomIn.stamp_start),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ masterSig: denomIn.master_sig,
+ fees: {
+ feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
+ feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
+ feeRefund: Amounts.stringify(denomGroup.fee_refund),
+ feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
+ },
+ };
+ currentDenominations.push(rec);
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+
+ return {
+ masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
+ currency,
+ baseUrl: exchangeKeysJsonUnchecked.base_url,
+ auditors: exchangeKeysJsonUnchecked.auditors,
+ currentDenominations,
+ protocolVersion: exchangeKeysJsonUnchecked.version,
+ signingKeys: exchangeKeysJsonUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
+ expiry: AbsoluteTime.toProtocolTimestamp(
+ getExpiry(resp, {
+ minDuration: Duration.fromSpec({ hours: 1 }),
+ }),
+ ),
+ recoup: exchangeKeysJsonUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
+ globalFees: exchangeKeysJsonUnchecked.global_fees,
+ accounts: exchangeKeysJsonUnchecked.accounts,
+ wireFees: exchangeKeysJsonUnchecked.wire_fees,
+ };
+}
+
+async function downloadTosFromAcceptedFormat(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ timeout: Duration,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<ExchangeTosDownloadResult> {
+ let tosFound: ExchangeTosDownloadResult | undefined;
+ // Remove this when exchange supports multiple content-type in accept header
+ if (acceptedFormat)
+ for (const format of acceptedFormat) {
+ const resp = await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ format,
+ acceptLanguage,
+ );
+ if (resp.tosContentType === format) {
+ tosFound = resp;
+ break;
+ }
+ }
+ if (tosFound !== undefined) {
+ return tosFound;
+ }
+ // If none of the specified format was found try text/plain
+ return await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ "text/plain",
+ acceptLanguage,
+ );
+}
+
+/**
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * If the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
+ */
+async function startUpdateExchangeEntry(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+ logger.info(
+ `starting update of exchange entry ${exchangeBaseUrl}, forced=${
+ options.forceUpdate ?? false
+ }`,
+ );
+
+ const { notification } = await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ wex.ws.exchangeCache.clear();
+ return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
+ },
+ );
+
+ logger.trace("created exchange record");
+
+ if (notification) {
+ wex.ws.notify(notification);
+ }
+
+ const { oldExchangeState, newExchangeState, taskId } =
+ await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "operationRetries"] },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ throw Error("exchange not found");
+ }
+ const oldExchangeState = getExchangeState(r);
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready: {
+ const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(r.nextUpdateStamp),
+ );
+ // Only update if entry is outdated or update is forced.
+ if (
+ options.forceUpdate ||
+ AbsoluteTime.isExpired(nextUpdateTimestamp)
+ ) {
+ r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+ r.cachebreakNextUpdate = options.forceUpdate;
+ }
+ break;
+ }
+ case ExchangeEntryDbUpdateStatus.Initial:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(r);
+ const newExchangeState = getExchangeState(r);
+ // Reset retries for updating the exchange entry.
+ const taskId = TaskIdentifiers.forExchangeUpdate(r);
+ await tx.operationRetries.delete(taskId);
+ return { oldExchangeState, newExchangeState, taskId };
+ },
+ );
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ });
+ await wex.taskScheduler.resetTaskRetries(taskId);
+}
+
+/**
+ * Basic information about an exchange in a ready state.
+ */
+export interface ReadyExchangeSummary {
+ exchangeBaseUrl: string;
+ currency: string;
+ masterPub: string;
+ tosStatus: ExchangeTosStatus;
+ tosAcceptedEtag: string | undefined;
+ tosCurrentEtag: string | undefined;
+ wireInfo: WireInfo;
+ protocolVersionRange: string;
+ tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
+ scopeInfo: ScopeInfo;
+}
+
+async function internalWaitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ exchangeNotifFlag: AsyncFlag,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const operationId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: canonUrl,
+ });
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ logger.info(`waiting for ready exchange ${canonUrl}`);
+ const { exchange, exchangeDetails, retryInfo, scopeInfo } =
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(canonUrl);
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ canonUrl,
+ );
+ const retryInfo = await tx.operationRetries.get(operationId);
+ let scopeInfo: ScopeInfo | undefined = undefined;
+ if (exchange && exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+ return { exchange, exchangeDetails, retryInfo, scopeInfo };
+ },
+ );
+
+ if (!exchange) {
+ throw Error("exchange entry does not exist anymore");
+ }
+
+ let ready = false;
+
+ switch (exchange.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ ready = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ // If the update is forced,
+ // we wait until we're in a full "ready" state,
+ // as we're not happy with the stale information.
+ if (!options.forceUpdate) {
+ ready = true;
+ }
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ default: {
+ if (retryInfo) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ }
+ }
+ }
+
+ if (!ready) {
+ logger.info("waiting for exchange update notification");
+ await exchangeNotifFlag.wait();
+ logger.info("done waiting for exchange update notification");
+ exchangeNotifFlag.reset();
+ continue;
+ }
+
+ if (!exchangeDetails) {
+ throw Error("invariant failed");
+ }
+
+ if (!scopeInfo) {
+ throw Error("invariant failed");
+ }
+
+ const res: ReadyExchangeSummary = {
+ currency: exchangeDetails.currency,
+ exchangeBaseUrl: canonUrl,
+ masterPub: exchangeDetails.masterPublicKey,
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ tosAcceptedEtag: exchange.tosAcceptedEtag,
+ wireInfo: exchangeDetails.wireInfo,
+ protocolVersionRange: exchangeDetails.protocolVersionRange,
+ tosCurrentEtag: exchange.tosCurrentEtag,
+ tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
+ exchange.tosAcceptedTimestamp,
+ ),
+ scopeInfo,
+ };
+
+ if (options.expectedMasterPub) {
+ if (res.masterPub !== options.expectedMasterPub) {
+ throw Error(
+ "public key of the exchange does not match expected public key",
+ );
+ }
+ }
+ return res;
+ }
+}
+
+/**
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ *
+ * If the expectedMasterPub is given and does not match the actual
+ * master pub, an exception will be thrown. However, the exchange
+ * will still have been added as an ephemeral exchange entry.
+ */
+export async function fetchFreshExchange(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ if (!options.forceUpdate) {
+ const cachedResp = wex.ws.exchangeCache.get(baseUrl);
+ if (cachedResp) {
+ return cachedResp;
+ }
+ } else {
+ wex.ws.exchangeCache.clear();
+ }
+
+ await wex.taskScheduler.ensureRunning();
+
+ await startUpdateExchangeEntry(wex, baseUrl, {
+ forceUpdate: options.forceUpdate,
+ });
+
+ const resp = await waitReadyExchange(wex, baseUrl, options);
+ wex.ws.exchangeCache.put(baseUrl, resp);
+ return resp;
+}
+
+async function waitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ logger.trace(`waiting for exchange ${canonUrl} to become ready`);
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const exchangeNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === canonUrl
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ exchangeNotifFlag.raise();
+ }
+ });
+
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ exchangeNotifFlag.raise();
+ });
+
+ try {
+ const res = await internalWaitReadyExchange(
+ wex,
+ canonUrl,
+ exchangeNotifFlag,
+ options,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+function checkPeerPaymentsDisabled(
+ keysInfo: ExchangeKeysDownloadResult,
+): boolean {
+ const now = AbsoluteTime.now();
+ for (let gf of keysInfo.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.start_date),
+ AbsoluteTime.fromProtocolTimestamp(gf.end_date),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return false;
+ }
+ // No global fees, we can't do p2p payments!
+ return true;
+}
+
+function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
+ for (const gf of keysInfo.globalFees) {
+ if (!Amounts.isZero(gf.account_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.history_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.purse_fee)) {
+ return false;
+ }
+ }
+ for (const denom of keysInfo.currentDenominations) {
+ if (!Amounts.isZero(denom.fees.feeWithdraw)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeDeposit)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefund)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefresh)) {
+ return false;
+ }
+ }
+ for (const wft of Object.values(keysInfo.wireFees)) {
+ for (const wf of wft) {
+ if (!Amounts.isZero(wf.wire_fee)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * by fetching the /keys and /wire information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+export async function updateExchangeFromUrlHandler(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<TaskRunResult> {
+ logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
+
+ const oldExchangeRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
+ return TaskRunResult.finished();
+ }
+
+ let updateRequestedExplicitly = false;
+
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ logger.info(`not updating exchange in status "suspended"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.Initial:
+ logger.info(`not updating exchange in status "initial"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ updateRequestedExplicitly = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ // Only retry when scheduled to respect backoff
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ break;
+ default:
+ assertUnreachable(oldExchangeRec.updateStatus);
+ }
+
+ let refreshCheckNecessary = true;
+
+ if (!updateRequestedExplicitly) {
+ // If the update wasn't requested explicitly,
+ // check if we really need to update.
+
+ let nextUpdateStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextUpdateStamp,
+ );
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let updateNecessary = true;
+
+ if (
+ !AbsoluteTime.isNever(nextUpdateStamp) &&
+ !AbsoluteTime.isExpired(nextUpdateStamp)
+ ) {
+ logger.info(
+ `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextUpdateStamp,
+ )}`,
+ );
+ updateNecessary = false;
+ }
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.info(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+
+ if (!(updateNecessary || refreshCheckNecessary)) {
+ logger.trace("update not necessary, running again later");
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
+ );
+ }
+ }
+
+ // When doing the auto-refresh check, we always update
+ // the key info before that.
+
+ logger.trace("updating exchange /keys info");
+
+ const timeout = getExchangeRequestTimeout();
+
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ wex.http,
+ timeout,
+ wex.cancellationToken,
+ oldExchangeRec.cachebreakNextUpdate ?? false,
+ );
+
+ logger.trace("validating exchange wire info");
+
+ const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
+ if (!version) {
+ // Should have been validated earlier.
+ throw Error("unexpected invalid version");
+ }
+
+ const wireInfo = await validateWireInfo(
+ wex,
+ version.current,
+ keysInfo,
+ keysInfo.masterPublicKey,
+ );
+
+ const globalFees = await validateGlobalFees(
+ wex,
+ keysInfo.globalFees,
+ keysInfo.masterPublicKey,
+ );
+
+ if (keysInfo.baseUrl != exchangeBaseUrl) {
+ logger.warn("exchange base URL mismatch");
+ const errorDetail: TalerErrorDetail = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
+ {
+ urlWallet: exchangeBaseUrl,
+ urlExchange: keysInfo.baseUrl,
+ },
+ );
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail,
+ };
+ }
+
+ logger.trace("finished validating exchange /wire info");
+
+ // We download the text/plain version here,
+ // because that one needs to exist, and we
+ // will get the current etag from the response.
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ timeout,
+ ["text/plain"],
+ );
+
+ logger.trace("updating exchange info in database");
+
+ let ageMask = 0;
+ for (const x of keysInfo.currentDenominations) {
+ if (
+ isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) &&
+ x.denomPub.age_mask != 0
+ ) {
+ ageMask = x.denomPub.age_mask;
+ break;
+ }
+ }
+ let noFees = checkNoFees(keysInfo);
+ let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
+
+ const updated = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ "coinAvailability",
+ "denomLossEvents",
+ ],
+ },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return;
+ }
+
+ wex.ws.refreshCostCache.clear();
+ wex.ws.exchangeCache.clear();
+ wex.ws.denomInfoCache.clear();
+
+ const oldExchangeState = getExchangeState(r);
+ const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ let detailsPointerChanged = false;
+ if (!existingDetails) {
+ detailsPointerChanged = true;
+ }
+ let detailsIncompatible = false;
+ if (existingDetails) {
+ if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ if (existingDetails.currency !== keysInfo.currency) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ // FIXME: We need to do some consistency checks!
+ }
+ if (detailsIncompatible) {
+ logger.warn(
+ `exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
+ );
+ // We don't support this gracefully right now.
+ // See https://bugs.taler.net/n/8576
+ r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
+ r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
+ r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ r.cachebreakNextUpdate = true;
+ await tx.exchanges.put(r);
+ return {
+ oldExchangeState,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ r.updateRetryCounter = 0;
+ const newDetails: ExchangeDetailsRecord = {
+ auditors: keysInfo.auditors,
+ currency: keysInfo.currency,
+ masterPublicKey: keysInfo.masterPublicKey,
+ protocolVersionRange: keysInfo.protocolVersion,
+ reserveClosingDelay: keysInfo.reserveClosingDelay,
+ globalFees,
+ exchangeBaseUrl: r.baseUrl,
+ wireInfo,
+ ageMask,
+ };
+ r.noFees = noFees;
+ r.peerPaymentsDisabled = peerPaymentsDisabled;
+ r.tosCurrentEtag = tosDownload.tosEtag;
+ if (existingDetails?.rowId) {
+ newDetails.rowId = existingDetails.rowId;
+ }
+ r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ r.nextUpdateStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
+ ),
+ );
+ // New denominations might be available.
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ if (detailsPointerChanged) {
+ r.detailsPointer = {
+ currency: newDetails.currency,
+ masterPublicKey: newDetails.masterPublicKey,
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ }
+
+ r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
+ r.cachebreakNextUpdate = false;
+ await tx.exchanges.put(r);
+ const drRowId = await tx.exchangeDetails.put(newDetails);
+ checkDbInvariant(typeof drRowId.key === "number");
+
+ for (const sk of keysInfo.signingKeys) {
+ // FIXME: validate signing keys before inserting them
+ await tx.exchangeSignKeys.put({
+ exchangeDetailsRowId: drRowId.key,
+ masterSig: sk.master_sig,
+ signkeyPub: sk.key,
+ stampEnd: timestampProtocolToDb(sk.stamp_end),
+ stampExpire: timestampProtocolToDb(sk.stamp_expire),
+ stampStart: timestampProtocolToDb(sk.stamp_start),
+ });
+ }
+
+ // In the future: Filter out old denominations by index
+ const allOldDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const oldDenomByDph = new Map<string, DenominationRecord>();
+ for (const denom of allOldDenoms) {
+ oldDenomByDph.set(denom.denomPubHash, denom);
+ }
+
+ logger.trace("updating denominations in database");
+ const currentDenomSet = new Set<string>(
+ keysInfo.currentDenominations.map((x) => x.denomPubHash),
+ );
+
+ for (const currentDenom of keysInfo.currentDenominations) {
+ const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash);
+ if (oldDenom) {
+ // FIXME: Do consistency check, report to auditor if necessary.
+ // See https://bugs.taler.net/n/8594
+
+ // Mark lost denominations as lost.
+ if (currentDenom.isLost && !oldDenom.isLost) {
+ logger.warn(
+ `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
+ );
+ oldDenom.isLost = true;
+ await tx.denominations.put(currentDenom);
+ }
+ } else {
+ await tx.denominations.put(currentDenom);
+ }
+ }
+
+ // Update list issue date for all denominations,
+ // and mark non-offered denominations as such.
+ for (const x of allOldDenoms) {
+ if (!currentDenomSet.has(x.denomPubHash)) {
+ // FIXME: Here, an auditor report should be created, unless
+ // the denomination is really legally expired.
+ if (x.isOffered) {
+ x.isOffered = false;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=false`,
+ );
+ }
+ } else {
+ if (!x.isOffered) {
+ x.isOffered = true;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=true`,
+ );
+ }
+ }
+ await tx.denominations.put(x);
+ }
+
+ logger.trace("done updating denominations in database");
+
+ const denomLossResult = await handleDenomLoss(
+ wex,
+ tx,
+ newDetails.currency,
+ exchangeBaseUrl,
+ );
+
+ await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
+
+ const newExchangeState = getExchangeState(r);
+
+ return {
+ exchange: r,
+ exchangeDetails: newDetails,
+ oldExchangeState,
+ newExchangeState,
+ denomLossResult,
+ };
+ },
+ );
+
+ if (!updated) {
+ throw Error("something went wrong with updating the exchange");
+ }
+
+ if (updated.denomLossResult) {
+ for (const notif of updated.denomLossResult.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ logger.trace("done updating exchange info in database");
+
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ let minCheckThreshold = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 1 }),
+ );
+
+ if (refreshCheckNecessary) {
+ // Do auto-refresh.
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "exchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange || !exchange.detailsPointer) {
+ return;
+ }
+ const coins = await tx.coins.indexes.byBaseUrl
+ .iter(exchangeBaseUrl)
+ .toArray();
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination not in database");
+ continue;
+ }
+ const executeThreshold =
+ getAutoRefreshExecuteThresholdForDenom(denom);
+ if (AbsoluteTime.isExpired(executeThreshold)) {
+ refreshCoins.push({
+ coinPub: coin.coinPub,
+ amount: denom.value,
+ });
+ } else {
+ const checkThreshold = getAutoRefreshCheckThreshold(denom);
+ minCheckThreshold = AbsoluteTime.min(
+ minCheckThreshold,
+ checkThreshold,
+ );
+ }
+ }
+ if (refreshCoins.length > 0) {
+ const res = await createRefreshGroup(
+ wex,
+ tx,
+ exchange.detailsPointer?.currency,
+ refreshCoins,
+ RefreshReason.Scheduled,
+ undefined,
+ );
+ logger.trace(
+ `created refresh group for auto-refresh (${res.refreshGroupId})`,
+ );
+ }
+ logger.trace(
+ `next refresh check at ${AbsoluteTime.toIsoString(
+ minCheckThreshold,
+ )}`,
+ );
+ exchange.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
+ );
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(exchange);
+ },
+ );
+ }
+
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
+interface DenomLossResult {
+ notifications: WalletNotification[];
+}
+
+async function handleDenomLoss(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coinAvailability", "denominations", "denomLossEvents", "coins"]
+ >,
+ currency: string,
+ exchangeBaseUrl: string,
+): Promise<DenomLossResult> {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ const denomsVanished: string[] = [];
+ const denomsUnoffered: string[] = [];
+ const denomsExpired: string[] = [];
+ let amountVanished = Amount.zeroOfCurrency(currency);
+ let amountExpired = Amount.zeroOfCurrency(currency);
+ let amountUnoffered = Amount.zeroOfCurrency(currency);
+
+ const result: DenomLossResult = {
+ notifications: [],
+ };
+
+ for (const coinAv of coinAvailabilityRecs) {
+ if (coinAv.freshCoinCount <= 0) {
+ continue;
+ }
+ const n = coinAv.freshCoinCount;
+ const denom = await tx.denominations.get([
+ coinAv.exchangeBaseUrl,
+ coinAv.denomPubHash,
+ ]);
+ const timestampExpireDeposit = !denom
+ ? undefined
+ : timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!denom) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsVanished.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountVanished = amountVanished.add(total);
+ } else if (!denom.isOffered) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsUnoffered.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountUnoffered = amountUnoffered.add(total);
+ } else if (
+ timestampExpireDeposit &&
+ AbsoluteTime.isExpired(timestampExpireDeposit)
+ ) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsExpired.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountExpired = amountExpired.add(total);
+ } else {
+ // Denomination is still fine!
+ continue;
+ }
+
+ logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);
+
+ const coins = await tx.coins.indexes.byDenomPubHash.getAll(
+ coinAv.denomPubHash,
+ );
+ for (const coin of coins) {
+ switch (coin.status) {
+ case CoinStatus.Fresh:
+ case CoinStatus.FreshSuspended: {
+ coin.status = CoinStatus.DenomLoss;
+ await tx.coins.put(coin);
+ break;
+ }
+ }
+ }
+ }
+
+ if (denomsVanished.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountVanished.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsVanished,
+ eventType: DenomLossEventType.DenomVanished,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsUnoffered.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountUnoffered.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomUnoffered,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsExpired.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountExpired.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ return result;
+}
+
+export function computeDenomLossTransactionStatus(
+ rec: DenomLossEventRecord,
+): TransactionState {
+ switch (rec.status) {
+ case DenomLossStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DenomLossStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ }
+}
+
+export class DenomLossTransactionContext implements TransactionContext {
+ get taskId(): TaskIdStr | undefined {
+ return undefined;
+ }
+ transactionId: TransactionIdStr;
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ async deleteTransaction(): Promise<void> {
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const rec = await tx.denomLossEvents.get(this.denomLossEventId);
+ if (rec) {
+ const oldTxState = computeDenomLossTransactionStatus(rec);
+ await tx.denomLossEvents.delete(this.denomLossEventId);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.Deleted,
+ },
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ }
+
+ constructor(
+ private wex: WalletExecutionContext,
+ public denomLossEventId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ }
+}
+
+async function handleRecoup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "recoupGroups", "refreshGroups"]
+ >,
+ exchangeBaseUrl: string,
+ recoup: Recoup[],
+): Promise<void> {
+ // Handle recoup
+ const recoupDenomList = recoup;
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.denominations.get([
+ exchangeBaseUrl,
+ recoupInfo.h_denom_pub,
+ ]);
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ logger.trace("denom already revoked");
+ continue;
+ }
+ logger.info("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.denominations.put(oldDenom);
+ const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll(
+ recoupInfo.h_denom_pub,
+ );
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ logger.info("recouping coins", newlyRevokedCoinPubs);
+ await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
+ }
+}
+
+function getAutoRefreshExecuteThresholdForDenom(
+ d: DenominationRecord,
+): AbsoluteTime {
+ return getAutoRefreshExecuteThreshold({
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ });
+}
+
+/**
+ * Timestamp after which the wallet would do the next check for an auto-refresh.
+ */
+function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireDeposit),
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.75);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Find a payto:// URI of the exchange that is of one
+ * of the given target types.
+ *
+ * Throws if no matching account was found.
+ */
+export async function getExchangePaytoUri(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const details = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ },
+ );
+ const accounts = details?.wireInfo.accounts ?? [];
+ for (const account of accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error(
+ `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
+ supportedTargetTypes,
+ )}`,
+ );
+}
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<GetExchangeTosResult> {
+ const exch = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ getExchangeRequestTimeout(),
+ acceptedFormat,
+ acceptLanguage,
+ );
+
+ await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
+ const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+ if (updateExchangeEntry) {
+ updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(updateExchangeEntry);
+ }
+ });
+
+ return {
+ acceptedEtag: exch.tosAcceptedEtag,
+ currentEtag: tosDownload.tosEtag,
+ content: tosDownload.tosText,
+ contentType: tosDownload.tosContentType,
+ contentLanguage: tosDownload.tosContentLanguage,
+ tosStatus: exch.tosStatus,
+ tosAvailableLanguages: tosDownload.tosAvailableLanguages,
+ };
+}
+
+/**
+ * Parsed information about an exchange,
+ * obtained by requesting /keys.
+ */
+export interface ExchangeInfo {
+ keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ http,
+ Duration.getForever(),
+ CancellationToken.CONTINUE,
+ false,
+ );
+ return {
+ keys: keysInfo,
+ };
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ wex: WalletExecutionContext,
+): Promise<ExchangesListResponse> {
+ const exchanges: ExchangeListItem[] = [];
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRecords = await tx.exchanges.iter().toArray();
+ for (const r of exchangeRecords) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: r.baseUrl,
+ });
+ const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ const opRetryRecord = await tx.operationRetries.get(taskId);
+ exchanges.push(
+ await makeExchangeListItem(
+ tx,
+ r,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ ),
+ );
+ }
+ },
+ );
+ return { exchanges };
+}
+
+/**
+ * Transition an exchange to the "used" entry state if necessary.
+ *
+ * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
+ *
+ * The caller should emit the returned notification iff the current transaction
+ * succeeded.
+ */
+export async function markExchangeUsed(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+): Promise<{ notif: WalletNotification | undefined }> {
+ logger.info(`marking exchange ${exchangeBaseUrl} as used`);
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exch) {
+ return {
+ notif: undefined,
+ };
+ }
+ const oldExchangeState = getExchangeState(exch);
+ switch (exch.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ case ExchangeEntryDbRecordStatus.Preset: {
+ exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ notif: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification,
+ };
+ }
+ default:
+ return {
+ notif: undefined,
+ };
+ }
+}
+
+/**
+ * Get detailed information about the exchange including a timeline
+ * for the fees charged by the exchange.
+ */
+export async function getExchangeDetailedInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+ const exchange = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
+ async (tx) => {
+ const ex = await tx.exchanges.get(exchangeBaseurl);
+ const dp = ex?.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency } = dp;
+ const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
+ if (!exchangeDetails) {
+ return;
+ }
+ const denominationRecords =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
+
+ if (!denominationRecords) {
+ return;
+ }
+
+ const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+ DenominationRecord.toDenomInfo(x),
+ );
+
+ return {
+ info: {
+ exchangeBaseUrl: ex.baseUrl,
+ currency,
+ paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+ auditors: exchangeDetails.auditors,
+ wireInfo: exchangeDetails.wireInfo,
+ globalFees: exchangeDetails.globalFees,
+ },
+ denominations,
+ };
+ },
+ );
+
+ if (!exchange) {
+ throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+ }
+
+ const denoms = exchange.denominations.map((d) => ({
+ ...d,
+ group: Amounts.stringifyValue(d.value),
+ }));
+ const denomFees: DenomOperationMap<FeeDescription[]> = {
+ deposit: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refresh: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefresh",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refund: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefund",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ withdraw: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeWithdraw",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ };
+
+ const transferFees = Object.entries(
+ exchange.info.wireInfo.feesForType,
+ ).reduce(
+ (prev, [wireType, infoForType]) => {
+ const feesByGroup = [
+ ...infoForType.map((w) => ({
+ ...w,
+ fee: Amounts.stringify(w.closingFee),
+ group: "closing",
+ })),
+ ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+ ];
+ prev[wireType] = createTimeline(
+ feesByGroup,
+ "sig",
+ "startStamp",
+ "endStamp",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+ return prev;
+ },
+ {} as Record<string, FeeDescription[]>,
+ );
+
+ const globalFeesByGroup = [
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.accountFee,
+ group: "account",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.historyFee,
+ group: "history",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.purseFee,
+ group: "purse",
+ })),
+ ];
+
+ const globalFees = createTimeline(
+ globalFeesByGroup,
+ "signature",
+ "startDate",
+ "endDate",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+
+ return {
+ exchange: {
+ ...exchange.info,
+ denomFees,
+ transferFees,
+ globalFees,
+ },
+ };
+}
+
+async function internalGetExchangeResources(
+ wex: WalletExecutionContext,
+ tx: DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ ["exchanges", "coins", "withdrawalGroups"]
+ >,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ let numWithdrawals = 0;
+ let numCoins = 0;
+ numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
+ numWithdrawals =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
+ const total = numWithdrawals + numCoins;
+ return {
+ hasResources: total != 0,
+ };
+}
+
+/**
+ * Purge information in the database associated with the exchange.
+ *
+ * Deletes information specific to the exchange and withdrawals,
+ * but keeps some transactions (payments, p2p, refreshes) around.
+ */
+async function purgeExchange(
+ tx: WalletDbReadWriteTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ]
+ >,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ for (const r of detRecs) {
+ if (r.rowId == null) {
+ // Should never happen, as rowId is the primary key.
+ continue;
+ }
+ await tx.exchangeDetails.delete(r.rowId);
+ const signkeyRecs =
+ await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
+ for (const rec of signkeyRecs) {
+ await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
+ }
+ }
+ // FIXME: Also remove records related to transactions?
+ await tx.exchanges.delete(exchangeBaseUrl);
+
+ {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const rec of coinAvailabilityRecs) {
+ await tx.coinAvailability.delete([
+ exchangeBaseUrl,
+ rec.denomPubHash,
+ rec.maxAge,
+ ]);
+ }
+ }
+
+ {
+ const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of coinRecs) {
+ await tx.coins.delete(rec.coinPub);
+ }
+ }
+
+ {
+ const denomRecs =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of denomRecs) {
+ await tx.denominations.delete(rec.denomPubHash);
+ }
+ }
+
+ {
+ const withdrawalGroupRecs =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const wg of withdrawalGroupRecs) {
+ await tx.withdrawalGroups.delete(wg.withdrawalGroupId);
+ const planchets = await tx.planchets.indexes.byGroup.getAll(
+ wg.withdrawalGroupId,
+ );
+ for (const p of planchets) {
+ await tx.planchets.delete(p.coinPub);
+ }
+ }
+ }
+}
+
+export async function deleteExchange(
+ wex: WalletExecutionContext,
+ req: DeleteExchangeRequest,
+): Promise<void> {
+ let inUse: boolean = false;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ if (res.hasResources && !req.purge) {
+ inUse = true;
+ return;
+ }
+ await purgeExchange(tx, exchangeBaseUrl);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+
+ if (inUse) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ hint: "Exchange in use.",
+ });
+ }
+}
+
+export async function getExchangeResources(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ // Withdrawals include internal withdrawals from peer transactions
+ const res = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "withdrawalGroups", "coins"] },
+ async (tx) => {
+ const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRecord) {
+ return undefined;
+ }
+ return internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ },
+ );
+ if (!res) {
+ throw Error("exchange not found");
+ }
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/host-common.ts b/packages/taler-wallet-core/src/host-common.ts
index c56d7ed1c..7651e5a12 100644
--- a/packages/taler-wallet-core/src/host-common.ts
+++ b/packages/taler-wallet-core/src/host-common.ts
@@ -16,7 +16,6 @@
import { WalletNotification } from "@gnu-taler/taler-util";
import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
-import { WalletConfigParameter } from "./index.js";
/**
* Helpers to initiate a wallet in a host environment.
@@ -45,11 +44,6 @@ export interface DefaultNodeWalletArgs {
httpLib?: HttpRequestLibrary;
cryptoWorkerType?: "sync" | "node-worker-thread";
-
- /**
- * Config parameters
- */
- config?: WalletConfigParameter;
}
/**
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
index fefee1067..ec026b296 100644
--- a/packages/taler-wallet-core/src/host-impl.node.ts
+++ b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -25,22 +25,24 @@
import type { IDBFactory } from "@gnu-taler/idb-bridge";
// eslint-disable-next-line no-duplicate-imports
import {
+ AccessStats,
BridgeIDBFactory,
MemoryBackend,
createSqliteBackend,
shimIndexedDB,
} from "@gnu-taler/idb-bridge";
-import { AccessStats } from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
+import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import * as fs from "fs";
import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js";
import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
-import { openTalerDatabase } from "./index.js";
-import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
-import { SetTimeoutTimerAPI } from "./util/timer.js";
-import { Wallet } from "./wallet.js";
import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
-import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings";
+import { Wallet } from "./wallet.js";
const logger = new Logger("host-impl.node.ts");
@@ -68,7 +70,11 @@ async function makeFileDb(
logger.trace("wallet file doesn't exist yet");
} else {
logger.error("could not open wallet database file");
- throw e;
+ throw Error(
+ "could not open wallet database file",
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
}
}
@@ -104,8 +110,10 @@ async function makeSqliteDb(
): Promise<MakeDbResult> {
BridgeIDBFactory.enableTracing = false;
const imp = await createNodeSqlite3Impl();
+ const dbFilename = args.persistentStoragePath ?? ":memory:";
+ logger.info(`using database ${dbFilename}`);
const myBackend = await createSqliteBackend(imp, {
- filename: args.persistentStoragePath ?? ":memory:",
+ filename: dbFilename,
});
myBackend.enableTracing = false;
if (process.env.TALER_WALLET_DBSTATS) {
@@ -131,15 +139,18 @@ export async function createNativeWalletHost2(
wallet: Wallet;
getDbStats: () => AccessStats;
}> {
- let myHttpLib;
- if (args.httpLib) {
- myHttpLib = args.httpLib;
- } else {
- myHttpLib = createPlatformHttpLib({
- enableThrottling: true,
- requireTls: !args.config?.features?.allowHttp,
- });
- }
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
let dbResp: MakeDbResult;
@@ -150,7 +161,7 @@ export async function createNativeWalletHost2(
logger.info("using JSON file DB backend (slow, only use for testing)");
dbResp = await makeFileDb(args);
} else {
- logger.info("using sqlite3 DB backend");
+ logger.info(`using sqlite3 DB backend`);
dbResp = await makeSqliteDb(args);
}
@@ -186,10 +197,9 @@ export async function createNativeWalletHost2(
const w = await Wallet.create(
myIdbFactory,
- myHttpLib,
+ myHttpFactory,
timer,
workerFactory,
- args.config,
);
if (args.notifyHandler) {
diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts
index 0fc346b44..9c985d0c1 100644
--- a/packages/taler-wallet-core/src/host-impl.qtart.ts
+++ b/packages/taler-wallet-core/src/host-impl.qtart.ts
@@ -36,12 +36,15 @@ import {
createSqliteBackend,
shimIndexedDB,
} from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart";
import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
-import { SetTimeoutTimerAPI } from "./util/timer.js";
import { Wallet } from "./wallet.js";
const logger = new Logger("host-impl.qtart.ts");
@@ -181,15 +184,18 @@ export async function createNativeWalletHost2(
shimIndexedDB(dbResp.idbFactory);
- let myHttpLib;
- if (args.httpLib) {
- myHttpLib = args.httpLib;
- } else {
- myHttpLib = createPlatformHttpLib({
- enableThrottling: true,
- requireTls: !args.config?.features?.allowHttp,
- });
- }
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
let workerFactory;
workerFactory = new SynchronousCryptoWorkerFactoryPlain();
@@ -198,10 +204,9 @@ export async function createNativeWalletHost2(
const w = await Wallet.create(
myIdbFactory,
- myHttpLib,
+ myHttpFactory,
timer,
workerFactory,
- args.config,
);
if (args.notifyHandler) {
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
index 643d65620..fe2d3af15 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -18,43 +18,29 @@
* Module entry point for the wallet when used as a node module.
*/
-// Util functionality
-export * from "./util/promiseUtils.js";
-export * from "./util/query.js";
-
-export * from "./versions.js";
-
-export * from "./db.js";
-
-// Crypto and crypto workers
-// export * from "./crypto/workers/nodeThreadWorker.js";
-export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
+export * from "./crypto/cryptoImplementation.js";
+export * from "./crypto/cryptoTypes.js";
export {
- CryptoWorkerFactory,
CryptoDispatcher,
+ CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
-
-export * from "./pending-types.js";
-
-export { InternalWalletState } from "./internal-wallet-state.js";
+export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
+export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+export * from "./host-common.js";
+export * from "./host.js";
+export * from "./versions.js";
export * from "./wallet-api-types.js";
export * from "./wallet.js";
-export * from "./operations/backup/index.js";
-
-export * from "./operations/exchanges.js";
+export { parseTransactionIdentifier } from "./transactions.js";
-export * from "./operations/withdraw.js";
-export * from "./operations/refresh.js";
+export { createPairTimeline } from "./denominations.js";
-export * from "./dbless.js";
-
-export * from "./crypto/cryptoTypes.js";
-export * from "./crypto/cryptoImplementation.js";
-
-export * from "./util/timer.js";
-export * from "./util/denominations.js";
-
-export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
-export * from "./host-common.js";
-export * from "./host.js";
+// FIXME: Should these really be exported?!
+export {
+ WalletStoresV1,
+ deleteTalerDatabase,
+ exportDb,
+ importDb,
+} from "./db.js";
+export { DbAccess } from "./query.js";
diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
index de8515d09..03e702568 100644
--- a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
@@ -22,8 +22,12 @@ import {
TransactionAmountMode,
} from "@gnu-taler/taler-util";
import test, { ExecutionContext } from "ava";
-import { CoinInfo } from "./coinSelection.js";
-import { convertDepositAmountForAvailableCoins, getMaxDepositAmountForAvailableCoins, convertWithdrawalAmountFromAvailableCoins } from "./instructedAmountConversion.js";
+import {
+ CoinInfo,
+ convertDepositAmountForAvailableCoins,
+ convertWithdrawalAmountFromAvailableCoins,
+ getMaxDepositAmountForAvailableCoins,
+} from "./instructedAmountConversion.js";
function makeCurrencyHelper(currency: string) {
return (sx: TemplateStringsArray, ...vx: any[]) => {
diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
index 4365e6d32..1f7d95959 100644
--- a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -27,17 +27,27 @@ import {
GetPlanForOperationRequest,
TransactionAmountMode,
TransactionType,
+ checkDbInvariant,
parsePaytoUri,
strcmp,
} from "@gnu-taler/taler-util";
-import {
- DenominationRecord,
- InternalWalletState,
- getExchangeDetails,
- timestampProtocolFromDb,
-} from "../index.js";
-import { CoinInfo } from "./coinSelection.js";
-import { checkDbInvariant } from "./invariants.js";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+export interface CoinInfo {
+ id: string;
+ value: AmountJson;
+ denomDeposit: AmountJson;
+ denomWithdraw: AmountJson;
+ denomRefresh: AmountJson;
+ totalAvailable: number | undefined;
+ exchangeWire: AmountJson | undefined;
+ exchangePurse: AmountJson | undefined;
+ duration: Duration;
+ exchangeBaseUrl: string;
+ maxAge: number;
+}
/**
* If the operation going to be plan subtracts
@@ -61,8 +71,8 @@ function getOperationType(txType: TransactionType): OperationType {
txType === TransactionType.Withdrawal
? OperationType.Credit
: txType === TransactionType.Deposit
- ? OperationType.Debit
- : undefined;
+ ? OperationType.Debit
+ : undefined;
if (!operationType) {
throw Error(`operation type ${txType} not yet supported`);
}
@@ -132,21 +142,23 @@ interface AvailableCoins {
* of being cached
*/
async function getAvailableDenoms(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
op: TransactionType,
currency: string,
filters: CoinsFilter = {},
): Promise<AvailableCoins> {
const operationType = getOperationType(TransactionType.Deposit);
- return await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadOnly(async (tx) => {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
const list: CoinInfo[] = [];
const exchanges: Record<string, ExchangeInfo> = {};
@@ -155,7 +167,10 @@ async function getAvailableDenoms(
filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
for (const exchangeBaseUrl of filteredExchanges) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeBaseUrl,
+ );
// 1.- exchange has same currency
if (exchangeDetails?.currency !== currency) {
continue;
@@ -221,9 +236,10 @@ async function getAvailableDenoms(
//4.- filter coins restricted by age
if (operationType === OperationType.Credit) {
// FIXME: Use denom groups instead of querying all denominations!
- const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- exchangeBaseUrl,
- );
+ const ds =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
for (const denom of ds) {
const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(denom.stampExpireWithdraw),
@@ -300,7 +316,8 @@ async function getAvailableDenoms(
}
return { list, exchanges };
- });
+ },
+ );
}
function buildCoinInfoFromDenom(
@@ -331,14 +348,14 @@ function buildCoinInfoFromDenom(
}
export async function convertDepositAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: ConvertAmountRequest,
): Promise<AmountResponse> {
const amount = Amounts.parseOrThrow(req.amount);
// const filter = getCoinsFilter(req);
const denoms = await getAvailableDenoms(
- ws,
+ wex,
TransactionType.Deposit,
amount.currency,
{},
@@ -433,13 +450,13 @@ export function convertDepositAmountForAvailableCoins(
}
export async function getMaxDepositAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: GetAmountRequest,
): Promise<AmountResponse> {
// const filter = getCoinsFilter(req);
const denoms = await getAvailableDenoms(
- ws,
+ wex,
TransactionType.Deposit,
req.currency,
{},
@@ -476,25 +493,27 @@ export function getMaxDepositAmountForAvailableCoins(
}
export async function convertPeerPushAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: ConvertAmountRequest,
): Promise<AmountResponse> {
throw Error("to be implemented after 1.0");
}
+
export async function getMaxPeerPushAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: GetAmountRequest,
): Promise<AmountResponse> {
throw Error("to be implemented after 1.0");
}
+
export async function convertWithdrawalAmount(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: ConvertAmountRequest,
): Promise<AmountResponse> {
const amount = Amounts.parseOrThrow(req.amount);
const denoms = await getAvailableDenoms(
- ws,
+ wex,
TransactionType.Withdrawal,
amount.currency,
{},
diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts
deleted file mode 100644
index b1389a359..000000000
--- a/packages/taler-wallet-core/src/internal-wallet-state.ts
+++ /dev/null
@@ -1,227 +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/>
- */
-
-/**
- * Common interface of the internal wallet state. This object is passed
- * to the various operations (exchange management, withdrawal, refresh, reserve
- * management, etc.).
- *
- * Some operations can be accessed via this state object. This allows mutual
- * recursion between operations, without having cyclic dependencies between
- * the respective TypeScript files.
- *
- * (You can think of this as a "header file" for the wallet implementation.)
- */
-
-/**
- * Imports.
- */
-import {
- CancellationToken,
- CoinRefreshRequest,
- DenominationInfo,
- RefreshGroupId,
- RefreshReason,
- TransactionState,
- WalletNotification,
-} from "@gnu-taler/taler-util";
-import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
-import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
-import {
- ExchangeDetailsRecord,
- ExchangeEntryRecord,
- RefreshReasonDetails,
- WalletStoresV1,
-} from "./db.js";
-import { AsyncCondition } from "./util/promiseUtils.js";
-import {
- DbAccess,
- GetReadOnlyAccess,
- GetReadWriteAccess,
-} from "./util/query.js";
-import { TimerGroup } from "./util/timer.js";
-import { WalletConfig } from "./wallet-api-types.js";
-import { IDBFactory } from "@gnu-taler/idb-bridge";
-import { ReadyExchangeSummary } from "./index.js";
-
-export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
-export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
-
-export interface TrustInfo {
- isTrusted: boolean;
- isAudited: boolean;
-}
-
-export interface MerchantInfo {
- protocolVersionCurrent: number;
-}
-
-/**
- * Interface for merchant-related operations.
- */
-export interface MerchantOperations {
- getMerchantInfo(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- ): Promise<MerchantInfo>;
-}
-
-export interface RefreshOperations {
- createRefreshGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- }>,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
- reason: RefreshReason,
- reasonDetails?: RefreshReasonDetails,
- ): Promise<RefreshGroupId>;
-}
-
-/**
- * Interface for exchange-related operations.
- */
-export interface ExchangeOperations {
- // FIXME: Should other operations maybe always use
- // updateExchangeFromUrl?
- getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
- ): Promise<ExchangeDetailsRecord | undefined>;
- fetchFreshExchange(
- ws: InternalWalletState,
- baseUrl: string,
- options?: {
- forceNow?: boolean;
- cancellationToken?: CancellationToken;
- },
- ): Promise<ReadyExchangeSummary>;
-}
-
-export interface RecoupOperations {
- createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- exchangeBaseUrl: string,
- coinPubs: string[],
- ): Promise<string>;
-}
-
-export type NotificationListener = (n: WalletNotification) => void;
-
-export interface ActiveLongpollInfo {
- [opId: string]: {
- cancel: () => void;
- };
-}
-
-export type CancelFn = () => void;
-
-/**
- * Internal, shared wallet state that is used by the implementation
- * of wallet operations.
- *
- * FIXME: This should not be exported anywhere from the taler-wallet-core package,
- * as it's an opaque implementation detail.
- */
-export interface InternalWalletState {
- /**
- * Active longpoll operations.
- */
- activeLongpoll: ActiveLongpollInfo;
-
- cryptoApi: TalerCryptoInterface;
-
- timerGroup: TimerGroup;
- stopped: boolean;
-
- config: Readonly<WalletConfig>;
-
- /**
- * Asynchronous condition to interrupt the sleep of the
- * retry loop.
- *
- * Used to allow processing of new work faster.
- */
- workAvailable: AsyncCondition;
-
- listeners: NotificationListener[];
-
- initCalled: boolean;
-
- merchantInfoCache: Record<string, MerchantInfo>;
-
- exchangeOps: ExchangeOperations;
- recoupOps: RecoupOperations;
- merchantOps: MerchantOperations;
- refreshOps: RefreshOperations;
-
- isTaskLoopRunning: boolean;
-
- getTransactionState(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<typeof WalletStoresV1>,
- transactionId: string,
- ): Promise<TransactionState | undefined>;
-
- getDenomInfo(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- denominations: typeof WalletStoresV1.denominations;
- }>,
- exchangeBaseUrl: string,
- denomPubHash: string,
- ): Promise<DenominationInfo | undefined>;
-
- ensureWalletDbOpen(): Promise<void>;
-
- idb: IDBFactory;
- db: DbAccess<typeof WalletStoresV1>;
- http: HttpRequestLibrary;
-
- notify(n: WalletNotification): void;
-
- addNotificationListener(f: (n: WalletNotification) => void): CancelFn;
-
- /**
- * Stop ongoing processing.
- */
- stop(): void;
-
- /**
- * Run an async function after acquiring a list of locks, identified
- * by string tokens.
- */
- runSequentialized<T>(tokens: string[], f: () => Promise<T>): Promise<T>;
-
- /**
- * Ensure that a task loop is currently running.
- * Starts one if no task loop is running.
- */
- ensureTaskLoopRunning(): void;
-}
diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts
new file mode 100644
index 000000000..717de41ca
--- /dev/null
+++ b/packages/taler-wallet-core/src/observable-wrappers.ts
@@ -0,0 +1,295 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * @fileoverview Wrappers/proxies to make various interfaces observable.
+ */
+
+/**
+ * Imports.
+ */
+import { IDBDatabase } from "@gnu-taler/idb-bridge";
+import {
+ ObservabilityContext,
+ ObservabilityEventType,
+} from "@gnu-taler/taler-util";
+import { TaskIdStr } from "./common.js";
+import { TalerCryptoInterface } from "./index.js";
+import {
+ DbAccess,
+ DbReadOnlyTransaction,
+ DbReadWriteTransaction,
+ StoreNames,
+} from "./query.js";
+import { TaskScheduler } from "./shepherd.js";
+
+/**
+ * Task scheduler with extra observability events.
+ */
+export class ObservableTaskScheduler implements TaskScheduler {
+ constructor(
+ private impl: TaskScheduler,
+ private oc: ObservabilityContext,
+ ) {}
+
+ private taskDepCache = new Set<string>();
+
+ private declareDep(taskId: TaskIdStr): void {
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ if (!this.taskDepCache.has(taskId)) {
+ this.taskDepCache.add(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.DeclareTaskDependency,
+ taskId,
+ });
+ }
+ }
+
+ shutdown(): Promise<void> {
+ return this.impl.shutdown();
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return this.impl.getActiveTasks();
+ }
+
+ isIdle(): boolean {
+ return this.impl.isIdle();
+ }
+
+ ensureRunning(): Promise<void> {
+ return this.impl.ensureRunning();
+ }
+
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStart,
+ taskId,
+ });
+ return this.impl.startShepherdTask(taskId);
+ }
+
+ stopShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStop,
+ taskId,
+ });
+ return this.impl.stopShepherdTask(taskId);
+ }
+
+ resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ this.declareDep(taskId);
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ this.oc.observe({
+ type: ObservabilityEventType.TaskReset,
+ taskId,
+ });
+ return this.impl.resetTaskRetries(taskId);
+ }
+
+ async reload(): Promise<void> {
+ return this.impl.reload();
+ }
+}
+
+const locRegex = /\s*at\s*([a-zA-Z0-9_.!]*)\s*/;
+
+export function getCallerInfo(up: number = 2): string {
+ const stack = new Error().stack ?? "";
+ const identifies: string[] = [];
+ for (const line of stack.split("\n")) {
+ let l = line.match(locRegex);
+ if (l) {
+ identifies.push(l[1]);
+ }
+ }
+ return identifies.slice(up, up + 2).join("/");
+}
+
+export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private impl: DbAccess<StoreMap>,
+ private oc: ObservabilityContext,
+ ) {}
+ idbHandle(): IDBDatabase {
+ return this.impl.idbHandle();
+ }
+
+ async runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadWriteTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadOnlyTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadWriteTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runReadWriteTx(opts, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadOnlyTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ try {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ const ret = await this.impl.runReadOnlyTx(opts, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+}
+
+export function observeTalerCrypto(
+ impl: TalerCryptoInterface,
+ oc: ObservabilityContext,
+): TalerCryptoInterface {
+ return Object.fromEntries(
+ Object.keys(impl).map((name) => {
+ return [
+ name,
+ async (req: any) => {
+ oc.observe({
+ type: ObservabilityEventType.CryptoStart,
+ operation: name,
+ });
+ try {
+ const res = await (impl as any)[name](req);
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishSuccess,
+ operation: name,
+ });
+ return res;
+ } catch (e) {
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishError,
+ operation: name,
+ });
+ throw e;
+ }
+ },
+ ];
+ }),
+ ) as any;
+}
diff --git a/packages/taler-wallet-core/src/operations/README.md b/packages/taler-wallet-core/src/operations/README.md
deleted file mode 100644
index a40349d37..000000000
--- a/packages/taler-wallet-core/src/operations/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Wallet Operations
-
-This folder contains the implementations for all wallet operations that operate on the wallet state.
-
-To avoid cyclic dependencies, these files must **not** reference each other. Instead, other operations should only be accessed via injected dependencies.
-
-Avoiding cyclic dependencies is important for module bundlers.
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
deleted file mode 100644
index 1b6ff7844..000000000
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ /dev/null
@@ -1,599 +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/>
- */
-
-/**
- * Functions to compute the wallet's balance.
- *
- * There are multiple definition of the wallet's balance.
- * We use the following terminology:
- *
- * - "available": Balance that is available
- * for spending from transactions in their final state and
- * expected to be available from pending refreshes.
- *
- * - "pending-incoming": Expected (positive!) delta
- * to the available balance that we expect to have
- * after pending operations reach the "done" state.
- *
- * - "pending-outgoing": Amount that is currently allocated
- * to be spent, but the spend operation could still be aborted
- * and part of the pending-outgoing amount could be recovered.
- *
- * - "material": Balance that the wallet believes it could spend *right now*,
- * without waiting for any operations to complete.
- * This balance type is important when showing "insufficient balance" error messages.
- *
- * - "age-acceptable": Subset of the material balance that can be spent
- * with age restrictions applied.
- *
- * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
- * merchant (restricted via min age, exchange, auditor, wire_method).
- *
- * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
- * can accept via their supported wire methods.
- */
-
-/**
- * Imports.
- */
-import {
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- AmountJson,
- Amounts,
- BalanceFlag,
- BalancesResponse,
- canonicalizeBaseUrl,
- GetBalanceDetailRequest,
- Logger,
- parsePaytoUri,
- ScopeType,
-} from "@gnu-taler/taler-util";
-import {
- depositOperationNonfinalStatusRange,
- DepositOperationStatus,
- RefreshGroupRecord,
- WalletStoresV1,
- withdrawalGroupNonfinalRange,
- WithdrawalGroupStatus,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { getExchangeDetails } from "./exchanges.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("operations/balance.ts");
-
-interface WalletBalance {
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
- flagIncomingKyc: boolean;
- flagIncomingAml: boolean;
- flagIncomingConfirmation: boolean;
- flagOutgoingKyc: boolean;
-}
-
-/**
- * Compute the available amount that the wallet expects to get
- * out of a refresh group.
- */
-function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- let available = Amounts.zeroOfCurrency(r.currency);
- if (r.timestampFinished) {
- return available;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
- }
- return available;
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- depositGroups: typeof WalletStoresV1.depositGroups;
- }>,
-): Promise<BalancesResponse> {
- const balanceStore: Record<string, WalletBalance> = {};
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- const initBalance = (currency: string): WalletBalance => {
- const b = balanceStore[currency];
- if (!b) {
- balanceStore[currency] = {
- available: Amounts.zeroOfCurrency(currency),
- pendingIncoming: Amounts.zeroOfCurrency(currency),
- pendingOutgoing: Amounts.zeroOfCurrency(currency),
- flagIncomingAml: false,
- flagIncomingConfirmation: false,
- flagIncomingKyc: false,
- flagOutgoingKyc: false,
- };
- }
- return balanceStore[currency];
- };
-
- await tx.coinAvailability.iter().forEach((ca) => {
- const b = initBalance(ca.currency);
- const count = ca.visibleCoinCount ?? 0;
- for (let i = 0; i < count; i++) {
- b.available = Amounts.add(b.available, ca.value).amount;
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- const b = initBalance(r.currency);
- b.available = Amounts.add(
- b.available,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
-
- await tx.withdrawalGroups.indexes.byStatus
- .iter(withdrawalGroupNonfinalRange)
- .forEach((wgRecord) => {
- const b = initBalance(
- Amounts.currencyOf(wgRecord.denomsSel.totalWithdrawCost),
- );
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.Done:
- // Does not count as pendingIncoming
- return;
- case WithdrawalGroupStatus.PendingReady:
- case WithdrawalGroupStatus.AbortingBank:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- // Pending, but no special flag.
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.PendingKyc:
- b.flagIncomingKyc = true;
- break;
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.SuspendedAml:
- b.flagIncomingAml = true;
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- b.flagIncomingConfirmation = true;
- break;
- default:
- assertUnreachable(wgRecord.status);
- }
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- wgRecord.denomsSel.totalCoinValue,
- ).amount;
- });
-
- // FIXME: Use indexing to filter out final transactions.
- await tx.depositGroups.indexes.byStatus
- .iter(depositOperationNonfinalStatusRange)
- .forEach((dgRecord) => {
- const b = initBalance(Amounts.currencyOf(dgRecord.amount));
- switch (dgRecord.operationStatus) {
- case DepositOperationStatus.SuspendedKyc:
- case DepositOperationStatus.PendingKyc:
- b.flagOutgoingKyc = true;
- }
- });
-
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- const flags: BalanceFlag[] = [];
- if (v.flagIncomingAml) {
- flags.push(BalanceFlag.IncomingAml);
- }
- if (v.flagIncomingKyc) {
- flags.push(BalanceFlag.IncomingKyc);
- }
- if (v.flagIncomingConfirmation) {
- flags.push(BalanceFlag.IncomingConfirmation);
- }
- if (v.flagOutgoingKyc) {
- flags.push(BalanceFlag.OutgoingKyc);
- }
- balancesResponse.balances.push({
- scopeInfo: {
- // FIXME: obtain REAL scopeInfo instead of faking a global currency
- type: ScopeType.Global,
- currency: Amounts.currencyOf(v.available),
- },
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- // FIXME: This field is basically not implemented, do we even need it?
- hasPendingTransactions: false,
- // FIXME: This field is basically not implemented, do we even need it?
- requiresUserInput: false,
- flags,
- });
- });
-
- return balancesResponse;
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db
- .mktx((x) => [
- x.coins,
- x.coinAvailability,
- x.refreshGroups,
- x.purchases,
- x.withdrawalGroups,
- x.depositGroups,
- ])
- .runReadOnly(async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- });
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
-
-/**
- * Information about the balance for a particular payment to a particular
- * merchant.
- */
-export interface MerchantPaymentBalanceDetails {
- balanceAvailable: AmountJson;
-}
-
-export interface MerchantPaymentRestrictionsForBalance {
- currency: string;
- minAge: number;
- acceptedExchanges: AllowedExchangeInfo[];
- acceptedAuditors: AllowedAuditorInfo[];
- acceptedWireMethods: string[];
-}
-
-export interface AcceptableExchanges {
- /**
- * Exchanges accepted by the merchant, but wire method might not match.
- */
- acceptableExchanges: string[];
-
- /**
- * Exchanges accepted by the merchant, including a matching
- * wire method, i.e. the merchant can deposit coins there.
- */
- depositableExchanges: string[];
-}
-
-/**
- * Get all exchanges that are acceptable for a particular payment.
- */
-export async function getAcceptableExchangeBaseUrls(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<AcceptableExchanges> {
- const acceptableExchangeUrls = new Set<string>();
- const depositableExchangeUrls = new Set<string>();
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- // FIXME: We should have a DB index to look up all exchanges
- // for a particular auditor ...
-
- const canonExchanges = new Set<string>();
- const canonAuditors = new Set<string>();
-
- for (const exchangeHandle of req.acceptedExchanges) {
- const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
- canonExchanges.add(normUrl);
- }
-
- for (const auditorHandle of req.acceptedAuditors) {
- const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
- canonAuditors.add(normUrl);
- }
-
- await tx.exchanges.iter().forEachAsync(async (exchange) => {
- const dp = exchange.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
- exchange.baseUrl,
- currency,
- masterPublicKey,
- ]);
- if (!exchangeDetails) {
- return;
- }
-
- let acceptable = false;
-
- if (canonExchanges.has(exchange.baseUrl)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- }
- for (const exchangeAuditor of exchangeDetails.auditors) {
- if (canonAuditors.has(exchangeAuditor.auditor_url)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- break;
- }
- }
-
- if (!acceptable) {
- return;
- }
- // FIXME: Also consider exchange and auditor public key
- // instead of just base URLs?
-
- let wireMethodSupported = false;
- for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- for (const wm of req.acceptedWireMethods) {
- if (pp.targetType === wm) {
- wireMethodSupported = true;
- break;
- }
- if (wireMethodSupported) {
- break;
- }
- }
- }
-
- acceptableExchangeUrls.add(exchange.baseUrl);
- if (wireMethodSupported) {
- depositableExchangeUrls.add(exchange.baseUrl);
- }
- });
- });
- return {
- acceptableExchanges: [...acceptableExchangeUrls],
- depositableExchanges: [...depositableExchangeUrls],
- };
-}
-
-export interface MerchantPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-
- /**
- * Balance of type "age-acceptable" (see balance.ts for definition).
- */
- balanceAgeAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-acceptable" (see balance.ts for definition).
- */
- balanceMerchantAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-depositable" (see balance.ts for definition).
- */
- balanceMerchantDepositable: AmountJson;
-}
-
-export async function getMerchantPaymentBalanceDetails(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<MerchantPaymentBalanceDetails> {
- const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
-
- const d: MerchantPaymentBalanceDetails = {
- balanceAvailable: Amounts.zeroOfCurrency(req.currency),
- balanceMaterial: Amounts.zeroOfCurrency(req.currency),
- balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
- };
-
- await ws.db
- .mktx((x) => [
- x.coins,
- x.coinAvailability,
- x.refreshGroups,
- x.purchases,
- x.withdrawalGroups,
- ])
- .runReadOnly(async (tx) => {
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
- d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
- if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
- d.balanceAgeAcceptable = Amounts.add(
- d.balanceAgeAcceptable,
- coinAmount,
- ).amount;
- if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
- d.balanceMerchantAcceptable = Amounts.add(
- d.balanceMerchantAcceptable,
- coinAmount,
- ).amount;
- if (
- acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
- ) {
- d.balanceMerchantDepositable = Amounts.add(
- d.balanceMerchantDepositable,
- coinAmount,
- ).amount;
- }
- }
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- d.balanceAvailable = Amounts.add(
- d.balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
- });
-
- return d;
-}
-
-export async function getBalanceDetail(
- ws: InternalWalletState,
- req: GetBalanceDetailRequest,
-): Promise<MerchantPaymentBalanceDetails> {
- const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
- const wires = new Array<string>();
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeDetails(tx, e.baseUrl);
- if (!details || req.currency !== details.currency) {
- continue;
- }
- details.wireInfo.accounts.forEach((a) => {
- const payto = parsePaytoUri(a.payto_uri);
- if (payto && !wires.includes(payto.targetType)) {
- wires.push(payto.targetType);
- }
- });
- exchanges.push({
- exchangePub: details.masterPublicKey,
- exchangeBaseUrl: e.baseUrl,
- });
- }
- });
-
- return await getMerchantPaymentBalanceDetails(ws, {
- currency: req.currency,
- acceptedAuditors: [],
- acceptedExchanges: exchanges,
- acceptedWireMethods: wires,
- minAge: 0,
- });
-}
-
-export interface PeerPaymentRestrictionsForBalance {
- currency: string;
- restrictExchangeTo?: string;
-}
-
-export interface PeerPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-}
-
-export async function getPeerPaymentBalanceDetailsInTx(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- }>,
- req: PeerPaymentRestrictionsForBalance,
-): Promise<PeerPaymentBalanceDetails> {
- let balanceAvailable = Amounts.zeroOfCurrency(req.currency);
- let balanceMaterial = Amounts.zeroOfCurrency(req.currency);
-
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- if (
- req.restrictExchangeTo &&
- req.restrictExchangeTo !== ca.exchangeBaseUrl
- ) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount;
- balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount;
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- balanceAvailable = Amounts.add(
- balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
-
- return {
- balanceAvailable,
- balanceMaterial,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
deleted file mode 100644
index 8f878ecc0..000000000
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ /dev/null
@@ -1,1433 +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/>
- */
-
-/**
- * @fileoverview
- * Implementation of exchange entry management in wallet-core.
- * The details of exchange entry management are specified in DD48.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- Amounts,
- CancellationToken,
- DenomKeyType,
- DenomOperationMap,
- DenominationInfo,
- DenominationPubKey,
- Duration,
- ExchangeAuditor,
- ExchangeDetailedResponse,
- ExchangeGlobalFees,
- ExchangeListItem,
- ExchangeSignKeyJson,
- ExchangeTosStatus,
- ExchangeWireAccount,
- ExchangesListResponse,
- FeeDescription,
- GetExchangeTosResult,
- GlobalFees,
- LibtoolVersion,
- Logger,
- NotificationType,
- Recoup,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolDuration,
- TalerProtocolTimestamp,
- URL,
- WalletNotification,
- WireFee,
- WireFeeMap,
- WireFeesJson,
- WireInfo,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- durationFromSpec,
- encodeCrock,
- hashDenomPub,
- j2s,
- makeErrorDetail,
- parsePaytoUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- getExpiry,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "@gnu-taler/taler-util/http";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeEntryRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- OpenedPromise,
- PendingTaskType,
- WalletDbReadWriteTransaction,
- createTimeline,
- isWithdrawableDenom,
- openPromise,
- selectBestForOverlappingDenominations,
- selectMinimumFee,
- timestampOptionalAbsoluteFromDb,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { CancelFn, InternalWalletState } from "../internal-wallet-state.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
-import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- getExchangeState,
- getExchangeTosStatusFromRecord,
- makeExchangeListItem,
- runTaskWithErrorReporting,
-} from "./common.js";
-
-const logger = new Logger("exchanges.ts");
-
-function getExchangeRequestTimeout(): Duration {
- return Duration.fromSpec({
- seconds: 5,
- });
-}
-
-interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
- tosContentLanguage: string | undefined;
- tosAvailableLanguages: string[];
-}
-
-async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- acceptFormat: string,
- acceptLanguage: string | undefined,
-): Promise<ExchangeTosDownloadResult> {
- logger.trace(`downloading exchange tos (type ${acceptFormat})`);
- const reqUrl = new URL("terms", exchangeBaseUrl);
- const headers: {
- Accept: string;
- "Accept-Language"?: string;
- } = {
- Accept: acceptFormat,
- };
-
- if (acceptLanguage) {
- headers["Accept-Language"] = acceptLanguage;
- }
-
- const resp = await http.fetch(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentLanguage = resp.headers.get("content-language") || undefined;
- const tosContentType = resp.headers.get("content-type") || "text/plain";
- const availLangStr = resp.headers.get("avail-languages") || "";
- // Work around exchange bug that reports the same language multiple times.
- const availLangSet = new Set<string>(
- availLangStr.split(",").map((x) => x.trim()),
- );
- const tosAvailableLanguages = [...availLangSet];
-
- return {
- tosText,
- tosEtag,
- tosContentType,
- tosContentLanguage,
- tosAvailableLanguages,
- };
-}
-
-/**
- * Get exchange details from the database.
- *
- * FIXME: Should we encapsulate the result better, instead of returning the raw DB records here?
- */
-export async function getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.indexes.byPointer.get([
- r.baseUrl,
- currency,
- masterPublicKey,
- ]);
-}
-
-/**
- * Mark a ToS version as accepted by the user.
- *
- * @param etag version of the ToS to accept, or current ToS version of not given
- */
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- etag: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (exch && exch.tosCurrentEtag) {
- exch.tosAcceptedEtag = exch.tosCurrentEtag;
- exch.tosAcceptedTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- await tx.exchanges.put(exch);
- }
- });
-}
-
-async function validateWireInfo(
- ws: InternalWalletState,
- versionCurrent: number,
- wireInfo: ExchangeKeysDownloadResult,
- masterPublicKey: string,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireAccount({
- masterPub: masterPublicKey,
- paytoUri: a.payto_uri,
- sig: a.master_sig,
- versionCurrent,
- conversionUrl: a.conversion_url,
- creditRestrictions: a.credit_restrictions,
- debitRestrictions: a.debit_restrictions,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- logger.trace("account validation done");
- const feesForType: WireFeeMap = {};
- for (const wireMethod of Object.keys(wireInfo.wireFees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.wireFees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.stringify(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.stringify(x.wire_fee),
- };
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireFee({
- masterPub: masterPublicKey,
- type: wireMethod,
- wf: fee,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-async function validateGlobalFees(
- ws: InternalWalletState,
- fees: GlobalFees[],
- masterPub: string,
-): Promise<ExchangeGlobalFees[]> {
- const egf: ExchangeGlobalFees[] = [];
- for (const gf of fees) {
- logger.trace("validating exchange global fees");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidGlobalFees({
- masterPub,
- gf,
- });
- isValid = v;
- }
-
- if (!isValid) {
- throw Error("exchange global fees signature invalid: " + gf.master_sig);
- }
- egf.push({
- accountFee: Amounts.stringify(gf.account_fee),
- historyFee: Amounts.stringify(gf.history_fee),
- purseFee: Amounts.stringify(gf.purse_fee),
- startDate: gf.start_date,
- endDate: gf.end_date,
- signature: gf.master_sig,
- historyTimeout: gf.history_expiration,
- purseLimit: gf.purse_account_limit,
- purseTimeout: gf.purse_timeout,
- });
- }
-
- return egf;
-}
-
-/**
- * Add an exchange entry to the wallet database in the
- * entry state "preset".
- *
- * Returns the notification to the caller that should be emitted
- * if the DB transaction succeeds.
- */
-export async function addPresetExchangeEntry(
- tx: WalletDbReadWriteTransaction<"exchanges">,
- exchangeBaseUrl: string,
- currencyHint?: string,
-): Promise<{ notification?: WalletNotification }> {
- let exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Preset,
- updateStatus: ExchangeEntryDbUpdateStatus.Initial,
- baseUrl: exchangeBaseUrl,
- presetCurrencyHint: currencyHint,
- detailsPointer: undefined,
- lastUpdate: undefined,
- lastKeysEtag: undefined,
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- return {
- notification: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: exchangeBaseUrl,
- // Exchange did not exist yet
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- },
- };
- }
- return {};
-}
-
-async function provideExchangeRecordInTx(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- baseUrl: string,
- now: AbsoluteTime,
-): Promise<{
- exchange: ExchangeEntryRecord;
- exchangeDetails: ExchangeDetailsRecord | undefined;
- notification?: WalletNotification;
-}> {
- let notification: WalletNotification | undefined = undefined;
- let exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
- updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
- baseUrl: baseUrl,
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- lastKeysEtag: undefined,
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- exchange = r;
- notification = {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: r.baseUrl,
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- };
- }
- const exchangeDetails = await getExchangeDetails(tx, baseUrl);
- return { exchange, exchangeDetails, notification };
-}
-
-export interface ExchangeKeysDownloadResult {
- baseUrl: string;
- masterPublicKey: string;
- currency: string;
- auditors: ExchangeAuditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: TalerProtocolDuration;
- expiry: TalerProtocolTimestamp;
- recoup: Recoup[];
- listIssueDate: TalerProtocolTimestamp;
- globalFees: GlobalFees[];
- accounts: ExchangeWireAccount[];
- wireFees: { [methodName: string]: WireFeesJson[] };
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadExchangeKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
-
- const resp = await http.fetch(keysUrl.href, {
- timeout,
- });
-
- // We must make sure to parse out the protocol version
- // before we validate the body.
- // Otherwise the parser might complain with a hard to understand
- // message about some other field, when it is just a version
- // incompatibility.
-
- const keysJson = await resp.json();
-
- const protocolVersion = keysJson.version;
- if (typeof protocolVersion !== "string") {
- throw Error("bad exchange, does not even specify protocol version");
- }
-
- const versionRes = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- protocolVersion,
- );
- if (!versionRes) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- {
- requestUrl: resp.requestUrl,
- httpStatusCode: resp.status,
- requestMethod: resp.requestMethod,
- },
- "exchange protocol version malformed",
- );
- }
- if (!versionRes.compatible) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- "exchange protocol version not compatible with wallet",
- );
- }
-
- const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- if (exchangeKeysJsonUnchecked.denominations.length === 0) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- {
- exchangeBaseUrl: baseUrl,
- },
- "exchange doesn't offer any denominations",
- );
- }
-
- const currency = exchangeKeysJsonUnchecked.currency;
-
- const currentDenominations: DenominationRecord[] = [];
-
- for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
- switch (denomGroup.cipher) {
- case "RSA":
- case "RSA+age_restricted": {
- let ageMask = 0;
- if (denomGroup.cipher === "RSA+age_restricted") {
- ageMask = denomGroup.age_mask;
- }
- for (const denomIn of denomGroup.denoms) {
- const denomPub: DenominationPubKey = {
- age_mask: ageMask,
- cipher: DenomKeyType.Rsa,
- rsa_public_key: denomIn.rsa_pub,
- };
- const denomPubHash = encodeCrock(hashDenomPub(denomPub));
- const value = Amounts.parseOrThrow(denomGroup.value);
- const rec: DenominationRecord = {
- denomPub,
- denomPubHash,
- exchangeBaseUrl: baseUrl,
- exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
- isOffered: true,
- isRevoked: false,
- value: Amounts.stringify(value),
- currency: value.currency,
- stampExpireDeposit: timestampProtocolToDb(
- denomIn.stamp_expire_deposit,
- ),
- stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
- stampExpireWithdraw: timestampProtocolToDb(
- denomIn.stamp_expire_withdraw,
- ),
- stampStart: timestampProtocolToDb(denomIn.stamp_start),
- verificationStatus: DenominationVerificationStatus.Unverified,
- masterSig: denomIn.master_sig,
- listIssueDate: timestampProtocolToDb(
- exchangeKeysJsonUnchecked.list_issue_date,
- ),
- fees: {
- feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
- feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
- feeRefund: Amounts.stringify(denomGroup.fee_refund),
- feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
- },
- };
- currentDenominations.push(rec);
- }
- break;
- }
- case "CS+age_restricted":
- case "CS":
- logger.warn("Clause-Schnorr denominations not supported");
- continue;
- default:
- logger.warn(
- `denomination type ${(denomGroup as any).cipher} not supported`,
- );
- continue;
- }
- }
-
- return {
- masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
- currency,
- baseUrl: exchangeKeysJsonUnchecked.base_url,
- auditors: exchangeKeysJsonUnchecked.auditors,
- currentDenominations,
- protocolVersion: exchangeKeysJsonUnchecked.version,
- signingKeys: exchangeKeysJsonUnchecked.signkeys,
- reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
- expiry: AbsoluteTime.toProtocolTimestamp(
- getExpiry(resp, {
- minDuration: durationFromSpec({ hours: 1 }),
- }),
- ),
- recoup: exchangeKeysJsonUnchecked.recoup ?? [],
- listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
- globalFees: exchangeKeysJsonUnchecked.global_fees,
- accounts: exchangeKeysJsonUnchecked.accounts,
- wireFees: exchangeKeysJsonUnchecked.wire_fees,
- };
-}
-
-async function downloadTosFromAcceptedFormat(
- ws: InternalWalletState,
- baseUrl: string,
- timeout: Duration,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<ExchangeTosDownloadResult> {
- let tosFound: ExchangeTosDownloadResult | undefined;
- // Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat)
- for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format,
- acceptLanguage,
- );
- if (resp.tosContentType === format) {
- tosFound = resp;
- break;
- }
- }
- if (tosFound !== undefined) {
- return tosFound;
- }
- // If none of the specified format was found try text/plain
- return await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain",
- acceptLanguage,
- );
-}
-
-/**
- * Transition an exchange into an updating state.
- *
- * If the update is forced, the exchange is put into an updating state
- * even if the old information should still be up to date.
- *
- * If the exchange entry doesn't exist,
- * a new ephemeral entry is created.
- */
-export async function startUpdateExchangeEntry(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: { forceUpdate?: boolean } = {},
-): Promise<void> {
- const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- const now = AbsoluteTime.now();
-
- const { notification } = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now);
- });
-
- if (notification) {
- ws.notify(notification);
- }
-
- const { oldExchangeState, newExchangeState } = await ws.db
- .mktx((x) => [x.exchanges, x.operationRetries])
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(canonBaseUrl);
- if (!r) {
- throw Error("exchange not found");
- }
- const oldExchangeState = getExchangeState(r);
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Suspended:
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Ready: {
- const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(r.nextUpdateStamp),
- );
- // Only update if entry is outdated or update is forced.
- if (
- options.forceUpdate ||
- AbsoluteTime.isExpired(nextUpdateTimestamp)
- ) {
- r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
- }
- break;
- }
- case ExchangeEntryDbUpdateStatus.Initial:
- r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
- break;
- }
- await tx.exchanges.put(r);
- const newExchangeState = getExchangeState(r);
- // Reset retries for updating the exchange entry.
- const taskId = TaskIdentifiers.forExchangeUpdate(r);
- await tx.operationRetries.delete(taskId);
- return { oldExchangeState, newExchangeState };
- });
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: canonBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- });
- ws.workAvailable.trigger();
-}
-
-export interface NotificationWaiter {
- waitNext(): Promise<void>;
- cancel(): void;
-}
-
-export function createNotificationWaiter(
- ws: InternalWalletState,
- pred: (x: WalletNotification) => boolean,
-): NotificationWaiter {
- ws.ensureTaskLoopRunning();
- let cancelFn: CancelFn | undefined = undefined;
- let p: OpenedPromise<void> | undefined = undefined;
-
- return {
- cancel() {
- cancelFn?.();
- },
- waitNext(): Promise<void> {
- if (!p) {
- p = openPromise();
- cancelFn = ws.addNotificationListener((notif) => {
- if (pred(notif)) {
- // We got a notification that matches our predicate.
- // Resolve promise for existing waiters,
- // and create a new promise to wait for the next
- // notification occurrence.
- const myResolve = p?.resolve;
- const myCancel = cancelFn;
- p = undefined;
- cancelFn = undefined;
- myResolve?.();
- myCancel?.();
- }
- });
- }
- return p.promise;
- },
- };
-}
-
-/**
- * Basic information about an exchange in a ready state.
- */
-export interface ReadyExchangeSummary {
- exchangeBaseUrl: string;
- currency: string;
- masterPub: string;
- tosStatus: ExchangeTosStatus;
- tosAcceptedEtag: string | undefined;
- tosCurrentEtag: string | undefined;
- wireInfo: WireInfo;
- protocolVersionRange: string;
- tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
-}
-
-/**
- * Ensure that a fresh exchange entry exists for the given
- * exchange base URL.
- *
- * The cancellation token can be used to abort waiting for the
- * updated exchange entry.
- *
- * If an exchange entry for the database doesn't exist in the
- * DB, it will be added ephemerally.
- *
- * If the expectedMasterPub is given and does not match the actual
- * master pub, an exception will be thrown. However, the exchange
- * will still have been added as an ephemeral exchange entry.
- */
-export async function fetchFreshExchange(
- ws: InternalWalletState,
- baseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const canonUrl = canonicalizeBaseUrl(baseUrl);
- const operationId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: canonUrl,
- });
-
- const oldExchange = await ws.db
- .mktx((x) => [x.exchanges])
- .runReadOnly(async (tx) => {
- return tx.exchanges.get(canonUrl);
- });
-
- let needsUpdate = false;
-
- if (!oldExchange || options.forceUpdate) {
- needsUpdate = true;
- await startUpdateExchangeEntry(ws, canonUrl, {
- forceUpdate: options.forceUpdate,
- });
- } else {
- const nextUpdate = timestampOptionalAbsoluteFromDb(
- oldExchange.nextUpdateStamp,
- );
- if (
- nextUpdate == null ||
- AbsoluteTime.isExpired(nextUpdate) ||
- oldExchange.updateStatus !== ExchangeEntryDbUpdateStatus.Ready
- ) {
- needsUpdate = true;
- }
- }
-
- if (needsUpdate) {
- await runTaskWithErrorReporting(ws, operationId, () =>
- updateExchangeFromUrlHandler(ws, canonUrl),
- );
- }
-
- const { exchange, exchangeDetails } = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(canonUrl);
- const exchangeDetails = await getExchangeDetails(tx, canonUrl);
- return { exchange, exchangeDetails };
- });
-
- if (!exchange) {
- throw Error("exchange entry does not exist anymore");
- }
-
- switch (exchange.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Ready:
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- default:
- throw Error("unable to update exchange");
- }
-
- if (!exchangeDetails) {
- throw Error("invariant failed");
- }
-
- const res: ReadyExchangeSummary = {
- currency: exchangeDetails.currency,
- exchangeBaseUrl: canonUrl,
- masterPub: exchangeDetails.masterPublicKey,
- tosStatus: getExchangeTosStatusFromRecord(exchange),
- tosAcceptedEtag: exchange.tosAcceptedEtag,
- wireInfo: exchangeDetails.wireInfo,
- protocolVersionRange: exchangeDetails.protocolVersionRange,
- tosCurrentEtag: exchange.tosCurrentEtag,
- tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
- exchange.tosAcceptedTimestamp,
- ),
- };
-
- if (options.expectedMasterPub) {
- if (res.masterPub !== options.expectedMasterPub) {
- throw Error(
- "public key of the exchange does not match expected public key",
- );
- }
- }
- return res;
-}
-
-/**
- * Update an exchange entry in the wallet's database
- * by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-export async function updateExchangeFromUrlHandler(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- } = {},
-): Promise<TaskRunResult> {
- logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- logger.trace("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout();
-
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- ws.http,
- timeout,
- );
-
- logger.trace("validating exchange wire info");
-
- const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
- if (!version) {
- // Should have been validated earlier.
- throw Error("unexpected invalid version");
- }
-
- const wireInfo = await validateWireInfo(
- ws,
- version.current,
- keysInfo,
- keysInfo.masterPublicKey,
- );
-
- const globalFees = await validateGlobalFees(
- ws,
- keysInfo.globalFees,
- keysInfo.masterPublicKey,
- );
- if (keysInfo.baseUrl != exchangeBaseUrl) {
- logger.warn("exchange base URL mismatch");
- const errorDetail: TalerErrorDetail = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
- {
- urlWallet: exchangeBaseUrl,
- urlExchange: keysInfo.baseUrl,
- },
- );
- return {
- type: TaskRunResultType.Error,
- errorDetail,
- };
- }
-
- logger.trace("finished validating exchange /wire info");
-
- // We download the text/plain version here,
- // because that one needs to exist, and we
- // will get the current etag from the response.
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- timeout,
- ["text/plain"],
- );
-
- let recoupGroupId: string | undefined;
-
- logger.trace("updating exchange info in database");
-
- let detailsPointerChanged = false;
-
- let ageMask = 0;
- for (const x of keysInfo.currentDenominations) {
- if (
- isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) &&
- x.denomPub.age_mask != 0
- ) {
- ageMask = x.denomPub.age_mask;
- break;
- }
- }
-
- const updated = await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.exchangeSignKeys,
- x.denominations,
- x.coins,
- x.refreshGroups,
- x.recoupGroups,
- ])
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
- return;
- }
- const oldExchangeState = getExchangeState(r);
- const existingDetails = await getExchangeDetails(tx, r.baseUrl);
- if (!existingDetails) {
- detailsPointerChanged = true;
- }
- if (existingDetails) {
- if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
- detailsPointerChanged = true;
- }
- if (existingDetails.currency !== keysInfo.currency) {
- detailsPointerChanged = true;
- }
- // FIXME: We need to do some consistency checks!
- }
- const newDetails: ExchangeDetailsRecord = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersionRange: keysInfo.protocolVersion,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- globalFees,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- ageMask,
- };
- r.tosCurrentEtag = tosDownload.tosEtag;
- if (existingDetails?.rowId) {
- newDetails.rowId = existingDetails.rowId;
- }
- r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
- r.nextUpdateStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
- ),
- );
- // New denominations might be available.
- r.nextRefreshCheckStamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- if (detailsPointerChanged) {
- r.detailsPointer = {
- currency: newDetails.currency,
- masterPublicKey: newDetails.masterPublicKey,
- updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- }
- r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
- await tx.exchanges.put(r);
- const drRowId = await tx.exchangeDetails.put(newDetails);
- checkDbInvariant(typeof drRowId.key === "number");
-
- for (const sk of keysInfo.signingKeys) {
- // FIXME: validate signing keys before inserting them
- await tx.exchangeSignKeys.put({
- exchangeDetailsRowId: drRowId.key,
- masterSig: sk.master_sig,
- signkeyPub: sk.key,
- stampEnd: timestampProtocolToDb(sk.stamp_end),
- stampExpire: timestampProtocolToDb(sk.stamp_expire),
- stampStart: timestampProtocolToDb(sk.stamp_start),
- });
- }
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- exchangeBaseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate);
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.info("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.info("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- exchangeBaseUrl,
- newlyRevokedCoinPubs,
- );
- }
-
- const newExchangeState = getExchangeState(r);
-
- return {
- exchange: r,
- exchangeDetails: newDetails,
- oldExchangeState,
- newExchangeState,
- };
- });
-
- if (recoupGroupId) {
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.workAvailable.trigger();
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: updated.newExchangeState,
- oldExchangeState: updated.oldExchangeState,
- });
-
- return TaskRunResult.finished();
-}
-
-/**
- * Find a payto:// URI of the exchange that is of one
- * of the given target types.
- *
- * Throws if no matching account was found.
- */
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const details = await ws.db
- .mktx((x) => [x.exchangeDetails, x.exchanges])
- .runReadOnly(async (tx) => {
- return getExchangeDetails(tx, exchangeBaseUrl);
- });
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error(
- `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
- supportedTargetTypes,
- )}`,
- );
-}
-
-/**
- * Get the exchange ToS in the requested format.
- * Try to download in the accepted format not cached.
- */
-export async function getExchangeTos(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<GetExchangeTosResult> {
- // FIXME: download ToS in acceptable format if passed!
- const exch = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- getExchangeRequestTimeout(),
- acceptedFormat,
- acceptLanguage,
- );
-
- await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
- const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
- if (updateExchangeEntry) {
- updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
- await tx.exchanges.put(updateExchangeEntry);
- }
- });
-
- return {
- acceptedEtag: exch.tosAcceptedEtag,
- currentEtag: tosDownload.tosEtag,
- content: tosDownload.tosText,
- contentType: tosDownload.tosContentType,
- contentLanguage: tosDownload.tosContentLanguage,
- tosStatus: exch.tosStatus,
- tosAvailableLanguages: tosDownload.tosAvailableLanguages,
- };
-}
-
-export interface ExchangeInfo {
- keys: ExchangeKeysDownloadResult;
-}
-
-/**
- * Helper function to download the exchange /keys info.
- *
- * Only used for testing / dbless wallet.
- */
-export async function downloadExchangeInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
-): Promise<ExchangeInfo> {
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- http,
- Duration.getForever(),
- );
- return {
- keys: keysInfo,
- };
-}
-
-export async function getExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListResponse> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.operationRetries,
- ])
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
- const opRetryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(r),
- );
- exchanges.push(
- makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
- );
- }
- });
- return { exchanges };
-}
-
-export async function getExchangeDetailedInfo(
- ws: InternalWalletState,
- exchangeBaseurl: string,
-): Promise<ExchangeDetailedResponse> {
- //TODO: should we use the forceUpdate parameter?
- const exchange = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
- .runReadOnly(async (tx) => {
- const ex = await tx.exchanges.get(exchangeBaseurl);
- const dp = ex?.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
- if (!exchangeDetails) {
- return;
- }
- const denominationRecords =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
-
- if (!denominationRecords) {
- return;
- }
-
- const denominations: DenominationInfo[] = denominationRecords.map((x) =>
- DenominationRecord.toDenomInfo(x),
- );
-
- return {
- info: {
- exchangeBaseUrl: ex.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
- auditors: exchangeDetails.auditors,
- wireInfo: exchangeDetails.wireInfo,
- globalFees: exchangeDetails.globalFees,
- },
- denominations,
- };
- });
-
- if (!exchange) {
- throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
- }
-
- const denoms = exchange.denominations.map((d) => ({
- ...d,
- group: Amounts.stringifyValue(d.value),
- }));
- const denomFees: DenomOperationMap<FeeDescription[]> = {
- deposit: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireDeposit",
- "feeDeposit",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refresh: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefresh",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refund: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefund",
- "group",
- selectBestForOverlappingDenominations,
- ),
- withdraw: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeWithdraw",
- "group",
- selectBestForOverlappingDenominations,
- ),
- };
-
- const transferFees = Object.entries(
- exchange.info.wireInfo.feesForType,
- ).reduce(
- (prev, [wireType, infoForType]) => {
- const feesByGroup = [
- ...infoForType.map((w) => ({
- ...w,
- fee: Amounts.stringify(w.closingFee),
- group: "closing",
- })),
- ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
- ];
- prev[wireType] = createTimeline(
- feesByGroup,
- "sig",
- "startStamp",
- "endStamp",
- "fee",
- "group",
- selectMinimumFee,
- );
- return prev;
- },
- {} as Record<string, FeeDescription[]>,
- );
-
- const globalFeesByGroup = [
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.accountFee,
- group: "account",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.historyFee,
- group: "history",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.purseFee,
- group: "purse",
- })),
- ];
-
- const globalFees = createTimeline(
- globalFeesByGroup,
- "signature",
- "startDate",
- "endDate",
- "fee",
- "group",
- selectMinimumFee,
- );
-
- return {
- exchange: {
- ...exchange.info,
- denomFees,
- transferFees,
- globalFees,
- },
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts
deleted file mode 100644
index a148953f0..000000000
--- a/packages/taler-wallet-core/src/operations/merchants.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A..
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- canonicalizeBaseUrl,
- Logger,
- URL,
- codecForMerchantConfigResponse,
- LibtoolVersion,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-
-const logger = new Logger("taler-wallet-core:merchants.ts");
-
-export async function getMerchantInfo(
- ws: InternalWalletState,
- merchantBaseUrl: string,
-): Promise<MerchantInfo> {
- const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl);
-
- const existingInfo = ws.merchantInfoCache[canonBaseUrl];
- if (existingInfo) {
- return existingInfo;
- }
-
- const configUrl = new URL("config", canonBaseUrl);
- const resp = await ws.http.fetch(configUrl.href);
-
- const configResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantConfigResponse(),
- );
-
- logger.info(
- `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`,
- );
-
- const parsedVersion = LibtoolVersion.parseVersion(configResp.version);
- if (!parsedVersion) {
- throw Error("invalid merchant version");
- }
-
- const merchantInfo: MerchantInfo = {
- protocolVersionCurrent: parsedVersion.current,
- };
-
- ws.merchantInfoCache[canonBaseUrl] = merchantInfo;
- return merchantInfo;
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
deleted file mode 100644
index 72e9e2e4a..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
+++ /dev/null
@@ -1,927 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- 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 {
- AcceptPeerPullPaymentResponse,
- Amounts,
- CoinRefreshRequest,
- ConfirmPeerPullDebitRequest,
- ContractTermsUtil,
- ExchangePurseDeposits,
- HttpStatusCode,
- Logger,
- NotificationType,
- PeerContractTerms,
- PreparePeerPullDebitRequest,
- PreparePeerPullDebitResponse,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- codecForAny,
- codecForExchangeGetContractResponse,
- codecForPeerContractTerms,
- decodeCrock,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- j2s,
- parsePayPullUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import {
- InternalWalletState,
- PeerPullDebitRecordStatus,
- PeerPullPaymentIncomingRecord,
- PendingTaskType,
- RefreshOperationStatus,
- createRefreshGroup,
- timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
- stopLongpolling,
-} from "./transactions.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-
-const logger = new Logger("pay-peer-pull-debit.ts");
-
-async function handlePurseCreationConflict(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const pursePub = peerPullInc.pursePub;
- const errResp = await readTalerErrorResponse(resp);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await failPeerPullDebitTransaction(ws, pursePub);
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const sel = peerPullInc.coinSel;
- if (!sel) {
- throw Error("invalid state (coin selection expected)");
- }
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- case PeerPullDebitRecordStatus.SuspendedDeposit: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPullDebit.put(myPpi);
- });
- return TaskRunResult.finished();
-}
-
-async function processPeerPullDebitPendingDeposit(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const peerPullDebitId = peerPullInc.peerPullDebitId;
- const pursePub = peerPullInc.pursePub;
-
- const coinSel = peerPullInc.coinSel;
- if (!coinSel) {
- throw Error("invalid state, no coins selected");
- }
-
- const coins = await queryCoinInfosForSelection(ws, coinSel);
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- pursePub: peerPullInc.pursePub,
- coins,
- });
-
- const purseDepositUrl = new URL(
- `purses/${pursePub}/deposit`,
- peerPullInc.exchangeBaseUrl,
- );
-
- const depositPayload: ExchangePurseDeposits = {
- deposits: depositSigsResp.deposits,
- };
-
- if (logger.shouldLogTrace()) {
- logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
-
- const httpResp = await ws.http.fetch(purseDepositUrl.href, {
- method: "POST",
- body: depositPayload,
- });
- switch (httpResp.status) {
- case HttpStatusCode.Ok: {
- const resp = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForAny(),
- );
- logger.trace(`purse deposit response: ${j2s(resp)}`);
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const pi = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pi) {
- throw Error("peer pull payment not found anymore");
- }
- if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
- return;
- }
- const oldTxState = computePeerPullDebitTransactionState(pi);
- pi.status = PeerPullDebitRecordStatus.Done;
- const newTxState = computePeerPullDebitTransactionState(pi);
- await tx.peerPullDebit.put(pi);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- break;
- }
- case HttpStatusCode.Gone: {
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.peerPullDebit,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
- const pi = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pi) {
- throw Error("peer pull payment not found anymore");
- }
- if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
- return;
- }
- const oldTxState = computePeerPullDebitTransactionState(pi);
-
- const currency = Amounts.currencyOf(pi.totalCostEstimated);
- const coinPubs: CoinRefreshRequest[] = [];
-
- if (!pi.coinSel) {
- throw Error("invalid db state");
- }
-
- for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: pi.coinSel.contributions[i],
- coinPub: pi.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPullDebit,
- );
-
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- pi.abortRefreshGroupId = refresh.refreshGroupId;
- const newTxState = computePeerPullDebitTransactionState(pi);
- await tx.peerPullDebit.put(pi);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- break;
- }
- case HttpStatusCode.Conflict: {
- return handlePurseCreationConflict(ws, peerPullInc, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
- return TaskRunResult.finished();
-}
-
-async function processPeerPullDebitAbortingRefresh(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const peerPullDebitId = peerPullInc.peerPullDebitId;
- const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups, x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPullDebitRecordStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPullDebitRecordStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPullDebitRecordStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPullDebitRecordStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPullDebit.get(peerPullDebitId);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPullDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPullDebitTransactionState(newDg);
- await tx.peerPullDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.pending();
-}
-
-export async function processPeerPullDebit(
- ws: InternalWalletState,
- peerPullDebitId: string,
-): Promise<TaskRunResult> {
- const peerPullInc = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadOnly(async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- });
- if (!peerPullInc) {
- throw Error("peer pull debit not found");
- }
-
- switch (peerPullInc.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- return await processPeerPullDebitPendingDeposit(ws, peerPullInc);
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return await processPeerPullDebitAbortingRefresh(ws, peerPullInc);
- }
- return TaskRunResult.finished();
-}
-
-export async function confirmPeerPullDebit(
- ws: InternalWalletState,
- req: ConfirmPeerPullDebitRequest,
-): Promise<AcceptPeerPullPaymentResponse> {
- let peerPullDebitId: string;
-
- if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
- throw Error("invalid peer-pull-debit transaction identifier");
- }
- peerPullDebitId = parsedTx.peerPullDebitId;
- } else if (req.peerPullDebitId) {
- peerPullDebitId = req.peerPullDebitId;
- } else {
- throw Error("invalid request, transactionId or peerPullDebitId required");
- }
-
- const peerPullInc = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadOnly(async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- });
-
- if (!peerPullInc) {
- throw Error(
- `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`,
- );
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- const ppi = await ws.db
- .mktx((x) => [
- x.exchanges,
- x.coins,
- x.denominations,
- x.refreshGroups,
- x.peerPullDebit,
- x.coinAvailability,
- ])
- .runReadWrite(async (tx) => {
- await spendCoins(ws, tx, {
- // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPull,
- });
-
- const pi = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pi) {
- throw Error();
- }
- if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
- pi.status = PeerPullDebitRecordStatus.PendingDeposit;
- pi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- }
- await tx.peerPullDebit.put(pi);
- return pi;
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- return {
- transactionId,
- };
-}
-
-/**
- * Look up information about an incoming peer pull payment.
- * Store the results in the wallet DB.
- */
-export async function preparePeerPullDebit(
- ws: InternalWalletState,
- req: PreparePeerPullDebitRequest,
-): Promise<PreparePeerPullDebitResponse> {
- const uri = parsePayPullUri(req.talerUri);
-
- if (!uri) {
- throw Error("got invalid taler://pay-pull URI");
- }
-
- const existing = await ws.db
- .mktx((x) => [x.peerPullDebit, x.contractTerms])
- .runReadOnly(async (tx) => {
- const peerPullDebitRecord =
- await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
- uri.exchangeBaseUrl,
- uri.contractPriv,
- ]);
- if (!peerPullDebitRecord) {
- return;
- }
- const contractTerms = await tx.contractTerms.get(
- peerPullDebitRecord.contractTermsHash,
- );
- if (!contractTerms) {
- return;
- }
- return { peerPullDebitRecord, contractTerms };
- });
-
- if (existing) {
- return {
- amount: existing.peerPullDebitRecord.amount,
- amountRaw: existing.peerPullDebitRecord.amount,
- amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
- contractTerms: existing.contractTerms.contractTermsRaw,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- }),
- };
- }
-
- const exchangeBaseUrl = uri.exchangeBaseUrl;
- const contractPriv = uri.contractPriv;
- const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
- const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
-
- const contractResp = await readSuccessResponseJsonOrThrow(
- contractHttpResp,
- codecForExchangeGetContractResponse(),
- );
-
- const pursePub = contractResp.purse_pub;
-
- const dec = await ws.cryptoApi.decryptContractForDeposit({
- ciphertext: contractResp.econtract,
- contractPriv: contractPriv,
- pursePub: pursePub,
- });
-
- const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
-
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
-
- const purseStatus = await readSuccessResponseJsonOrThrow(
- purseHttpResp,
- codecForExchangePurseStatus(),
- );
-
- const peerPullDebitId = encodeCrock(getRandomBytes(32));
-
- let contractTerms: PeerContractTerms;
-
- if (dec.contractTerms) {
- contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
- // FIXME: Check that the purseStatus balance matches contract terms amount
- } else {
- // FIXME: In this case, where do we get the purse expiration from?!
- // https://bugs.gnunet.org/view.php?id=7706
- throw Error("pull payments without contract terms not supported yet");
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
-
- // FIXME: Why don't we compute the totalCost here?!
-
- const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db
- .mktx((x) => [x.peerPullDebit, x.contractTerms])
- .runReadWrite(async (tx) => {
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: contractTerms,
- }),
- await tx.peerPullDebit.add({
- peerPullDebitId,
- contractPriv: contractPriv,
- exchangeBaseUrl: exchangeBaseUrl,
- pursePub: pursePub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- contractTermsHash,
- amount: contractTerms.amount,
- status: PeerPullDebitRecordStatus.DialogProposed,
- totalCostEstimated: Amounts.stringify(totalAmount),
- });
- });
-
- return {
- amount: contractTerms.amount,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: contractTerms.amount,
- contractTerms: contractTerms,
- peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: peerPullDebitId,
- }),
- };
-}
-
-export async function suspendPeerPullDebitTransaction(
- ws: InternalWalletState,
- peerPullDebitId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- break;
- case PeerPullDebitRecordStatus.Done:
- break;
- case PeerPullDebitRecordStatus.PendingDeposit:
- newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.AbortingRefresh:
- newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPullDebitTransaction(
- ws: InternalWalletState,
- peerPullDebitId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- newStatus = PeerPullDebitRecordStatus.Aborted;
- break;
- case PeerPullDebitRecordStatus.Done:
- break;
- case PeerPullDebitRecordStatus.PendingDeposit:
- newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.AbortingRefresh:
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPullDebitTransaction(
- ws: InternalWalletState,
- peerPullDebitId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- newStatus = PeerPullDebitRecordStatus.Aborted;
- break;
- case PeerPullDebitRecordStatus.Done:
- break;
- case PeerPullDebitRecordStatus.PendingDeposit:
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- case PeerPullDebitRecordStatus.AbortingRefresh:
- // FIXME: abort underlying refresh!
- newStatus = PeerPullDebitRecordStatus.Failed;
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPullDebitTransaction(
- ws: InternalWalletState,
- peerPullDebitId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullDebit])
- .runReadWrite(async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- case PeerPullDebitRecordStatus.Done:
- case PeerPullDebitRecordStatus.PendingDeposit:
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- newStatus = PeerPullDebitRecordStatus.PendingDeposit;
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.AbortingRefresh:
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export function computePeerPullDebitTransactionState(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionState {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case PeerPullDebitRecordStatus.PendingDeposit:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPullDebitRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- }
-}
-
-export function computePeerPullDebitTransactionActions(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionAction[] {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return [];
- case PeerPullDebitRecordStatus.PendingDeposit:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Done:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPullDebitRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Failed:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
deleted file mode 100644
index 4c0292cc6..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
+++ /dev/null
@@ -1,1170 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- 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 {
- Amounts,
- CheckPeerPushDebitRequest,
- CheckPeerPushDebitResponse,
- CoinRefreshRequest,
- ContractTermsUtil,
- HttpStatusCode,
- InitiatePeerPushDebitRequest,
- InitiatePeerPushDebitResponse,
- Logger,
- NotificationType,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- encodeCrock,
- getRandomBytes,
- j2s,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import { EncryptContractRequest } from "../crypto/cryptoTypes.js";
-import {
- PeerPushDebitRecord,
- PeerPushDebitStatus,
- RefreshOperationStatus,
- createRefreshGroup,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- runLongpollAsync,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- stopLongpolling,
-} from "./transactions.js";
-
-const logger = new Logger("pay-peer-push-debit.ts");
-
-export async function checkPeerPushDebit(
- ws: InternalWalletState,
- req: CheckPeerPushDebitRequest,
-): Promise<CheckPeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(req.amount);
- logger.trace(
- `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
- );
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (coinSelRes.type === "failure") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
- logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`);
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
- logger.trace("computed total peer payment cost");
- return {
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: req.amount,
- maxExpirationDate: coinSelRes.result.maxExpirationDate,
- };
-}
-
-async function handlePurseCreationConflict(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const errResp = await readTalerErrorResponse(resp);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await failPeerPushDebitTransaction(ws, pursePub);
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
- const sel = peerPushInitiation.coinSel;
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.SuspendedCreatePurse: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPushDebit.put(myPpi);
- });
- return TaskRunResult.finished();
-}
-
-async function processPeerPushDebitCreateReserve(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const purseExpiration = peerPushInitiation.purseExpiration;
- const hContractTerms = peerPushInitiation.contractTermsHash;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePub,
- });
-
- logger.trace(`processing ${transactionId} pending(create-reserve)`);
-
- const contractTermsRecord = await ws.db
- .mktx((x) => [x.contractTerms])
- .runReadOnly(async (tx) => {
- return tx.contractTerms.get(hContractTerms);
- });
-
- if (!contractTermsRecord) {
- throw Error(
- `db invariant failed, contract terms for ${transactionId} missing`,
- );
- }
-
- const purseSigResp = await ws.cryptoApi.signPurseCreation({
- hContractTerms,
- mergePub: peerPushInitiation.mergePub,
- minAge: 0,
- purseAmount: peerPushInitiation.amount,
- purseExpiration: timestampProtocolFromDb(purseExpiration),
- pursePriv: peerPushInitiation.pursePriv,
- });
-
- const coins = await queryCoinInfosForSelection(
- ws,
- peerPushInitiation.coinSel,
- );
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- pursePub: peerPushInitiation.pursePub,
- coins,
- });
-
- const encryptContractRequest: EncryptContractRequest = {
- contractTerms: contractTermsRecord.contractTermsRaw,
- mergePriv: peerPushInitiation.mergePriv,
- pursePriv: peerPushInitiation.pursePriv,
- pursePub: peerPushInitiation.pursePub,
- contractPriv: peerPushInitiation.contractPriv,
- contractPub: peerPushInitiation.contractPub,
- nonce: peerPushInitiation.contractEncNonce,
- };
-
- logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
-
- const econtractResp = await ws.cryptoApi.encryptContractForMerge(
- encryptContractRequest,
- );
-
- const createPurseUrl = new URL(
- `purses/${peerPushInitiation.pursePub}/create`,
- peerPushInitiation.exchangeBaseUrl,
- );
-
- const reqBody = {
- amount: peerPushInitiation.amount,
- merge_pub: peerPushInitiation.mergePub,
- purse_sig: purseSigResp.sig,
- h_contract_terms: hContractTerms,
- purse_expiration: timestampProtocolFromDb(purseExpiration),
- deposits: depositSigsResp.deposits,
- min_age: 0,
- econtract: econtractResp.econtract,
- };
-
- logger.trace(`request body: ${j2s(reqBody)}`);
-
- const httpResp = await ws.http.fetch(createPurseUrl.href, {
- method: "POST",
- body: reqBody,
- });
-
- {
- const resp = await httpResp.json();
- logger.info(`resp: ${j2s(resp)}`);
- }
-
- switch (httpResp.status) {
- case HttpStatusCode.Ok:
- break;
- case HttpStatusCode.Forbidden: {
- // FIXME: Store this error!
- await failPeerPushDebitTransaction(ws, pursePub);
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Conflict: {
- // Handle double-spending
- return handlePurseCreationConflict(ws, peerPushInitiation, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
-
- if (httpResp.status !== HttpStatusCode.Ok) {
- // FIXME: do proper error reporting
- throw Error("got error response from exchange");
- }
-
- await transitionPeerPushDebitTransaction(ws, pursePub, {
- stFrom: PeerPushDebitStatus.PendingCreatePurse,
- stTo: PeerPushDebitStatus.PendingReady,
- });
-
- return TaskRunResult.finished();
-}
-
-async function processPeerPushDebitAbortingDeletePurse(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const { pursePub, pursePriv } = peerPushInitiation;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
-
- const sigResp = await ws.cryptoApi.signDeletePurse({
- pursePriv,
- });
- const purseUrl = new URL(
- `purses/${pursePub}`,
- peerPushInitiation.exchangeBaseUrl,
- );
- const resp = await ws.http.fetch(purseUrl.href, {
- method: "DELETE",
- headers: {
- "taler-purse-signature": sigResp.sig,
- },
- });
- logger.info(`deleted purse with response status ${resp.status}`);
-
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.peerPushDebit,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.pending();
-}
-
-interface SimpleTransition {
- stFrom: PeerPushDebitStatus;
- stTo: PeerPushDebitStatus;
-}
-
-async function transitionPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
- transitionSpec: SimpleTransition,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== transitionSpec.stFrom) {
- return undefined;
- }
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- ppiRec.status = transitionSpec.stTo;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processPeerPushDebitAbortingRefreshDeleted(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups, x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.pending();
-}
-
-async function processPeerPushDebitAbortingRefreshExpired(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups, x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Expired;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.pending();
-}
-
-/**
- * Process the "pending(ready)" state of a peer-push-debit transaction.
- */
-async function processPeerPushDebitReady(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- logger.trace("processing peer-push-debit pending(ready)");
- const pursePub = peerPushInitiation.pursePub;
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const transactionId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- runLongpollAsync(ws, retryTag, async (ct) => {
- const mergeUrl = new URL(
- `purses/${pursePub}/merge`,
- peerPushInitiation.exchangeBaseUrl,
- );
- mergeUrl.searchParams.set("timeout_ms", "30000");
- logger.info(`long-polling on purse status at ${mergeUrl.href}`);
- const resp = await ws.http.fetch(mergeUrl.href, {
- // timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken: ct,
- });
- if (resp.status === HttpStatusCode.Ok) {
- const purseStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangePurseStatus(),
- );
- const mergeTimestamp = purseStatus.merge_timestamp;
- logger.info(`got purse status ${j2s(purseStatus)}`);
- if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
- return { ready: false };
- } else {
- await transitionPeerPushDebitTransaction(
- ws,
- peerPushInitiation.pursePub,
- {
- stFrom: PeerPushDebitStatus.PendingReady,
- stTo: PeerPushDebitStatus.Done,
- },
- );
- return {
- ready: true,
- };
- }
- } else if (resp.status === HttpStatusCode.Gone) {
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.peerPushDebit,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return {
- ready: true,
- };
- } else {
- logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
- return {
- ready: false,
- };
- }
- });
- logger.trace(
- "returning early from peer-push-debit for long-polling in background",
- );
- return {
- type: TaskRunResultType.Longpoll,
- };
-}
-
-export async function processPeerPushDebit(
- ws: InternalWalletState,
- pursePub: string,
-): Promise<TaskRunResult> {
- const peerPushInitiation = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadOnly(async (tx) => {
- return tx.peerPushDebit.get(pursePub);
- });
- if (!peerPushInitiation) {
- throw Error("peer push payment not found");
- }
-
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
-
- // We're already running!
- if (ws.activeLongpoll[retryTag]) {
- logger.info("peer-push-debit task already in long-polling, returning!");
- return {
- type: TaskRunResultType.Longpoll,
- };
- }
-
- switch (peerPushInitiation.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
- case PeerPushDebitStatus.PendingReady:
- return processPeerPushDebitReady(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingDeletePurse:
- return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return processPeerPushDebitAbortingRefreshExpired(ws, peerPushInitiation);
- default: {
- const txState = computePeerPushDebitTransactionState(peerPushInitiation);
- logger.warn(
- `not processing peer-push-debit transaction in state ${j2s(txState)}`,
- );
- }
- }
-
- return TaskRunResult.finished();
-}
-
-/**
- * Initiate sending a peer-to-peer push payment.
- */
-export async function initiatePeerPushDebit(
- ws: InternalWalletState,
- req: InitiatePeerPushDebitRequest,
-): Promise<InitiatePeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(
- req.partialContractTerms.amount,
- );
- const purseExpiration = req.partialContractTerms.purse_expiration;
- const contractTerms = req.partialContractTerms;
-
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- logger.info(`selected p2p coins (push):`);
- logger.trace(`${j2s(coinSelRes)}`);
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- logger.info(`computed total peer payment cost`);
-
- const pursePub = pursePair.pub;
-
- const transactionId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
-
- const contractEncNonce = encodeCrock(getRandomBytes(24));
-
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.exchanges,
- x.contractTerms,
- x.coins,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- x.peerPushDebit,
- ])
- .runReadWrite(async (tx) => {
- // FIXME: Instead of directly doing a spendCoin here,
- // we might want to mark the coins as used and spend them
- // after we've been able to create the purse.
- await spendCoins(ws, tx, {
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPush,
- });
-
- const ppi: PeerPushDebitRecord = {
- amount: Amounts.stringify(instructedAmount),
- contractPriv: contractKeyPair.priv,
- contractPub: contractKeyPair.pub,
- contractTermsHash: hContractTerms,
- exchangeBaseUrl: sel.exchangeBaseUrl,
- mergePriv: mergePair.priv,
- mergePub: mergePair.pub,
- purseExpiration: timestampProtocolToDb(purseExpiration),
- pursePriv: pursePair.priv,
- pursePub: pursePair.pub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- status: PeerPushDebitStatus.PendingCreatePurse,
- contractEncNonce,
- coinSel: {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- },
- totalCost: Amounts.stringify(totalAmount),
- };
-
- await tx.peerPushDebit.add(ppi);
-
- await tx.contractTerms.put({
- h: hContractTerms,
- contractTermsRaw: contractTerms,
- });
-
- const newTxState = computePeerPushDebitTransactionState(ppi);
- return {
- oldTxState: { major: TransactionMajorState.None },
- newTxState,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- return {
- contractPriv: contractKeyPair.priv,
- mergePriv: mergePair.priv,
- pursePub: pursePair.pub,
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- };
-}
-
-export function computePeerPushDebitTransactionActions(
- ppiRecord: PeerPushDebitRecord,
-): TransactionAction[] {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.PendingReady:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.AbortingDeletePurse:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.Done:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Expired:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Failed:
- return [TransactionAction.Delete];
- }
-}
-
-export async function abortPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- // Network request might already be in-flight!
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Expired:
- case PeerPushDebitStatus.Failed:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- // FIXME: What to do about the refresh group?
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function suspendPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
- break;
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.AbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPushDebitStatus.PendingReady:
- newStatus = PeerPushDebitStatus.SuspendedReady;
- break;
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushDebit])
- .runReadWrite(async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.PendingReady;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- newStatus = PeerPushDebitStatus.PendingCreatePurse;
- break;
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export function computePeerPushDebitTransactionState(
- ppiRecord: PeerPushDebitRecord,
-): TransactionState {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPushDebitStatus.AbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.SuspendedReady:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPushDebitStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPushDebitStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
deleted file mode 100644
index 76b9fd801..000000000
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ /dev/null
@@ -1,803 +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/>
- */
-
-/**
- * Derive pending tasks from the wallet database.
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AbsoluteTime,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TransactionRecordFilter,
-} from "@gnu-taler/taler-util";
-import {
- BackupProviderStateTag,
- DbPreciseTimestamp,
- DepositElementStatus,
- DepositGroupRecord,
- ExchangeEntryDbUpdateStatus,
- PeerPullCreditRecord,
- PeerPullDebitRecordStatus,
- PeerPullPaymentCreditStatus,
- PeerPullPaymentIncomingRecord,
- PeerPushCreditStatus,
- PeerPushDebitRecord,
- PeerPushDebitStatus,
- PeerPushPaymentIncomingRecord,
- PurchaseRecord,
- PurchaseStatus,
- RefreshCoinStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
- RefundGroupRecord,
- RefundGroupStatus,
- RewardRecord,
- RewardRecordStatus,
- WalletStoresV1,
- WithdrawalGroupRecord,
- depositOperationNonfinalStatusRange,
- timestampAbsoluteFromDb,
- timestampOptionalAbsoluteFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- withdrawalGroupNonfinalRange,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- PendingOperationsResponse,
- PendingTaskType,
- TaskId,
-} from "../pending-types.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { TaskIdentifiers } from "./common.js";
-
-function getPendingCommon(
- ws: InternalWalletState,
- opTag: TaskId,
- timestampDue: AbsoluteTime,
-): {
- id: TaskId;
- isDue: boolean;
- timestampDue: AbsoluteTime;
- isLongpolling: boolean;
-} {
- const isDue =
- AbsoluteTime.isExpired(timestampDue) && !ws.activeLongpoll[opTag];
- return {
- id: opTag,
- isDue,
- timestampDue,
- isLongpolling: !!ws.activeLongpoll[opTag],
- };
-}
-
-async function gatherExchangePending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- let timestampDue: DbPreciseTimestamp | undefined = undefined;
- await tx.exchanges.iter().forEachAsync(async (exch) => {
- switch (exch.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Initial:
- case ExchangeEntryDbUpdateStatus.Suspended:
- return;
- }
- const opUpdateExchangeTag = TaskIdentifiers.forExchangeUpdate(exch);
- let opr = await tx.operationRetries.get(opUpdateExchangeTag);
-
- switch (exch.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Ready:
- timestampDue = opr?.retryInfo.nextRetry ?? exch.nextRefreshCheckStamp;
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- case ExchangeEntryDbUpdateStatus.InitialUpdate:
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- timestampDue =
- opr?.retryInfo.nextRetry ??
- timestampPreciseToDb(TalerPreciseTimestamp.now());
- break;
- }
-
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeUpdate,
- ...getPendingCommon(
- ws,
- opUpdateExchangeTag,
- AbsoluteTime.fromPreciseTimestamp(timestampPreciseFromDb(timestampDue)),
- ),
- givesLifeness: false,
- exchangeBaseUrl: exch.baseUrl,
- lastError: opr?.lastError,
- });
-
- // We only schedule a check for auto-refresh if the exchange update
- // was successful.
- if (!opr?.lastError) {
- const opCheckRefreshTag = TaskIdentifiers.forExchangeCheckRefresh(exch);
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeCheckRefresh,
- ...getPendingCommon(
- ws,
- opCheckRefreshTag,
- AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(timestampDue),
- ),
- ),
- timestampDue: AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(exch.nextRefreshCheckStamp),
- ),
- givesLifeness: false,
- exchangeBaseUrl: exch.baseUrl,
- });
- }
- });
-}
-
-/**
- * Iterate refresh records based on a filter.
- */
-export async function iterRecordsForRefresh(
- tx: GetReadOnlyAccess<{
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- }>,
- filter: TransactionRecordFilter,
- f: (r: RefreshGroupRecord) => Promise<void>,
-): Promise<void> {
- let refreshGroups: RefreshGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- RefreshOperationStatus.Pending,
- RefreshOperationStatus.Suspended,
- );
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
- } else {
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
- }
-
- for (const r of refreshGroups) {
- await f(r);
- }
-}
-
-async function gatherRefreshPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForRefresh(tx, { onlyState: "nonfinal" }, async (r) => {
- if (r.timestampFinished) {
- return;
- }
- const opId = TaskIdentifiers.forRefresh(r);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Refresh,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- refreshGroupId: r.refreshGroupId,
- finishedPerCoin: r.statusPerCoin.map(
- (x) => x === RefreshCoinStatus.Finished,
- ),
- retryInfo: retryRecord?.retryInfo,
- });
- });
-}
-
-export async function iterRecordsForWithdrawal(
- tx: GetReadOnlyAccess<{
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- }>,
- filter: TransactionRecordFilter,
- f: (r: WithdrawalGroupRecord) => Promise<void>,
-): Promise<void> {
- let withdrawalGroupRecords: WithdrawalGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- withdrawalGroupRecords = await tx.withdrawalGroups.indexes.byStatus.getAll(
- withdrawalGroupNonfinalRange,
- );
- } else {
- withdrawalGroupRecords =
- await tx.withdrawalGroups.indexes.byStatus.getAll();
- }
- for (const wgr of withdrawalGroupRecords) {
- await f(wgr);
- }
-}
-
-async function gatherWithdrawalPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- planchets: typeof WalletStoresV1.planchets;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForWithdrawal(tx, { onlyState: "nonfinal" }, async (wsr) => {
- const opTag = TaskIdentifiers.forWithdrawal(wsr);
- let opr = await tx.operationRetries.get(opTag);
- /**
- * kyc pending operation don't give lifeness
- * since the user need to complete kyc procedure
- */
- const userNeedToCompleteKYC = wsr.kycUrl !== undefined;
- const now = AbsoluteTime.now();
- if (!opr) {
- opr = {
- id: opTag,
- retryInfo: {
- firstTry: timestampPreciseToDb(AbsoluteTime.toPreciseTimestamp(now)),
- nextRetry: timestampPreciseToDb(AbsoluteTime.toPreciseTimestamp(now)),
- retryCounter: 0,
- },
- };
- }
- resp.pendingOperations.push({
- type: PendingTaskType.Withdraw,
- ...getPendingCommon(
- ws,
- opTag,
- timestampOptionalAbsoluteFromDb(opr.retryInfo?.nextRetry) ??
- AbsoluteTime.now(),
- ),
- givesLifeness: !userNeedToCompleteKYC,
- withdrawalGroupId: wsr.withdrawalGroupId,
- lastError: opr.lastError,
- retryInfo: opr.retryInfo,
- });
- });
-}
-
-export async function iterRecordsForDeposit(
- tx: GetReadOnlyAccess<{
- depositGroups: typeof WalletStoresV1.depositGroups;
- }>,
- filter: TransactionRecordFilter,
- f: (r: DepositGroupRecord) => Promise<void>,
-): Promise<void> {
- let dgs: DepositGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- dgs = await tx.depositGroups.indexes.byStatus.getAll(
- depositOperationNonfinalStatusRange,
- );
- } else {
- dgs = await tx.depositGroups.indexes.byStatus.getAll();
- }
-
- for (const dg of dgs) {
- await f(dg);
- }
-}
-
-async function gatherDepositPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- depositGroups: typeof WalletStoresV1.depositGroups;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForDeposit(tx, { onlyState: "nonfinal" }, async (dg) => {
- let deposited = true;
- for (const d of dg.statusPerCoin) {
- if (d === DepositElementStatus.DepositPending) {
- deposited = false;
- }
- }
- /**
- * kyc pending operation don't give lifeness
- * since the user need to complete kyc procedure
- */
- const userNeedToCompleteKYC = dg.kycInfo !== undefined;
- const opId = TaskIdentifiers.forDeposit(dg);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Deposit,
- ...getPendingCommon(ws, opId, timestampDue),
- // Fully deposited operations don't give lifeness,
- // because there is no reason to wait on the
- // deposit tracking status.
- givesLifeness: !deposited && !userNeedToCompleteKYC,
- depositGroupId: dg.depositGroupId,
- lastError: retryRecord?.lastError,
- retryInfo: retryRecord?.retryInfo,
- });
- });
-}
-
-export async function iterRecordsForReward(
- tx: GetReadOnlyAccess<{
- rewards: typeof WalletStoresV1.rewards;
- }>,
- filter: TransactionRecordFilter,
- f: (r: RewardRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const range = GlobalIDB.KeyRange.bound(
- RewardRecordStatus.PendingPickup,
- RewardRecordStatus.PendingPickup,
- );
- await tx.rewards.indexes.byStatus.iter(range).forEachAsync(f);
- } else {
- await tx.rewards.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherRewardPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- rewards: typeof WalletStoresV1.rewards;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForReward(tx, { onlyState: "nonfinal" }, async (tip) => {
- const opId = TaskIdentifiers.forTipPickup(tip);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
-
- /**
- * kyc pending operation don't give lifeness
- * since the user need to complete kyc procedure
- */
- // const userNeedToCompleteKYC = tip.
-
- if (tip.acceptedTimestamp) {
- resp.pendingOperations.push({
- type: PendingTaskType.RewardPickup,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- timestampDue,
- merchantBaseUrl: tip.merchantBaseUrl,
- tipId: tip.walletRewardId,
- merchantTipId: tip.merchantRewardId,
- });
- }
- });
-}
-
-export async function iterRecordsForRefund(
- tx: GetReadOnlyAccess<{
- refundGroups: typeof WalletStoresV1.refundGroups;
- }>,
- filter: TransactionRecordFilter,
- f: (r: RefundGroupRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.only(RefundGroupStatus.Pending);
- await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.refundGroups.iter().forEachAsync(f);
- }
-}
-
-export async function iterRecordsForPurchase(
- tx: GetReadOnlyAccess<{
- purchases: typeof WalletStoresV1.purchases;
- }>,
- filter: TransactionRecordFilter,
- f: (r: PurchaseRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- PurchaseStatus.PendingDownloadingProposal,
- PurchaseStatus.PendingAcceptRefund,
- );
- await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherPurchasePending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- purchases: typeof WalletStoresV1.purchases;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForPurchase(tx, { onlyState: "nonfinal" }, async (pr) => {
- const opId = TaskIdentifiers.forPay(pr);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Purchase,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- statusStr: PurchaseStatus[pr.purchaseStatus],
- proposalId: pr.proposalId,
- retryInfo: retryRecord?.retryInfo,
- lastError: retryRecord?.lastError,
- });
- });
-}
-
-async function gatherRecoupPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- // FIXME: Have a status field!
- await tx.recoupGroups.iter().forEachAsync(async (rg) => {
- if (rg.timestampFinished) {
- return;
- }
- const opId = TaskIdentifiers.forRecoup(rg);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Recoup,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- recoupGroupId: rg.recoupGroupId,
- retryInfo: retryRecord?.retryInfo,
- lastError: retryRecord?.lastError,
- });
- });
-}
-
-async function gatherBackupPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- backupProviders: typeof WalletStoresV1.backupProviders;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.backupProviders.iter().forEachAsync(async (bp) => {
- const opId = TaskIdentifiers.forBackup(bp);
- const retryRecord = await tx.operationRetries.get(opId);
- if (bp.state.tag === BackupProviderStateTag.Ready) {
- const timestampDue = timestampAbsoluteFromDb(
- bp.state.nextBackupTimestamp,
- );
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: false,
- backupProviderBaseUrl: bp.baseUrl,
- lastError: undefined,
- });
- } else if (bp.state.tag === BackupProviderStateTag.Retrying) {
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo?.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: false,
- backupProviderBaseUrl: bp.baseUrl,
- retryInfo: retryRecord?.retryInfo,
- lastError: retryRecord?.lastError,
- });
- }
- });
-}
-
-export async function iterRecordsForPeerPullInitiation(
- tx: GetReadOnlyAccess<{
- peerPullCredit: typeof WalletStoresV1.peerPullCredit;
- }>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullCreditRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- PeerPullPaymentCreditStatus.PendingCreatePurse,
- PeerPullPaymentCreditStatus.AbortingDeletePurse,
- );
- await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherPeerPullInitiationPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- peerPullCredit: typeof WalletStoresV1.peerPullCredit;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForPeerPullInitiation(
- tx,
- { onlyState: "nonfinal" },
- async (pi) => {
- const opId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
-
- /**
- * kyc pending operation don't give lifeness
- * since the user need to complete kyc procedure
- */
- const userNeedToCompleteKYC = pi.kycUrl !== undefined;
-
- resp.pendingOperations.push({
- type: PendingTaskType.PeerPullCredit,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: !userNeedToCompleteKYC,
- retryInfo: retryRecord?.retryInfo,
- pursePub: pi.pursePub,
- internalOperationStatus: `0x${pi.status.toString(16)}`,
- });
- },
- );
-}
-
-export async function iterRecordsForPeerPullDebit(
- tx: GetReadOnlyAccess<{
- peerPullDebit: typeof WalletStoresV1.peerPullDebit;
- }>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- PeerPullDebitRecordStatus.PendingDeposit,
- PeerPullDebitRecordStatus.AbortingRefresh,
- );
- await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherPeerPullDebitPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- peerPullDebit: typeof WalletStoresV1.peerPullDebit;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForPeerPullDebit(
- tx,
- { onlyState: "nonfinal" },
- async (pi) => {
- const opId = TaskIdentifiers.forPeerPullPaymentDebit(pi);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- switch (pi.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return;
- }
- resp.pendingOperations.push({
- type: PendingTaskType.PeerPullDebit,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- retryInfo: retryRecord?.retryInfo,
- peerPullDebitId: pi.peerPullDebitId,
- internalOperationStatus: `0x${pi.status.toString(16)}`,
- });
- },
- );
-}
-
-export async function iterRecordsForPeerPushInitiation(
- tx: GetReadOnlyAccess<{
- peerPushDebit: typeof WalletStoresV1.peerPushDebit;
- }>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushDebitRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- PeerPushDebitStatus.PendingCreatePurse,
- PeerPushDebitStatus.AbortingRefreshExpired,
- );
- await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherPeerPushInitiationPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- peerPushDebit: typeof WalletStoresV1.peerPushDebit;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await iterRecordsForPeerPushInitiation(
- tx,
- { onlyState: "nonfinal" },
- async (pi) => {
- const opId = TaskIdentifiers.forPeerPushPaymentInitiation(pi);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.PeerPushDebit,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- retryInfo: retryRecord?.retryInfo,
- pursePub: pi.pursePub,
- });
- },
- );
-}
-
-export async function iterRecordsForPeerPushCredit(
- tx: GetReadOnlyAccess<{
- peerPushCredit: typeof WalletStoresV1.peerPushCredit;
- }>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- PeerPushCreditStatus.PendingMerge,
- PeerPushCreditStatus.PendingWithdrawing,
- );
- await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function gatherPeerPushCreditPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- peerPushCredit: typeof WalletStoresV1.peerPushCredit;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- const keyRange = GlobalIDB.KeyRange.bound(
- PeerPushCreditStatus.PendingMerge,
- PeerPushCreditStatus.PendingWithdrawing,
- );
- await iterRecordsForPeerPushCredit(
- tx,
- { onlyState: "nonfinal" },
- async (pi) => {
- const opId = TaskIdentifiers.forPeerPushCredit(pi);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ??
- AbsoluteTime.now();
-
- /**
- * kyc pending operation don't give lifeness
- * since the user need to complete kyc procedure
- */
- const userNeedToCompleteKYC = pi.kycUrl !== undefined;
-
- resp.pendingOperations.push({
- type: PendingTaskType.PeerPushCredit,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: !userNeedToCompleteKYC,
- retryInfo: retryRecord?.retryInfo,
- peerPushCreditId: pi.peerPushCreditId,
- });
- },
- );
-}
-
-const taskPrio: { [X in PendingTaskType]: number } = {
- [PendingTaskType.Deposit]: 2,
- [PendingTaskType.ExchangeUpdate]: 1,
- [PendingTaskType.PeerPullCredit]: 2,
- [PendingTaskType.PeerPullDebit]: 2,
- [PendingTaskType.PeerPushCredit]: 2,
- [PendingTaskType.Purchase]: 2,
- [PendingTaskType.Recoup]: 3,
- [PendingTaskType.RewardPickup]: 2,
- [PendingTaskType.Refresh]: 3,
- [PendingTaskType.Withdraw]: 3,
- [PendingTaskType.ExchangeCheckRefresh]: 3,
- [PendingTaskType.PeerPushDebit]: 2,
- [PendingTaskType.Backup]: 4,
-};
-
-export async function getPendingOperations(
- ws: InternalWalletState,
-): Promise<PendingOperationsResponse> {
- const now = AbsoluteTime.now();
- const resp = await ws.db
- .mktx((x) => [
- x.backupProviders,
- x.exchanges,
- x.exchangeDetails,
- x.refreshGroups,
- x.coins,
- x.withdrawalGroups,
- x.rewards,
- x.purchases,
- x.planchets,
- x.depositGroups,
- x.recoupGroups,
- x.operationRetries,
- x.peerPullCredit,
- x.peerPushDebit,
- x.peerPullDebit,
- x.peerPushCredit,
- ])
- .runReadWrite(async (tx) => {
- const resp: PendingOperationsResponse = {
- pendingOperations: [],
- };
- await gatherExchangePending(ws, tx, now, resp);
- await gatherRefreshPending(ws, tx, now, resp);
- await gatherWithdrawalPending(ws, tx, now, resp);
- await gatherDepositPending(ws, tx, now, resp);
- await gatherRewardPending(ws, tx, now, resp);
- await gatherPurchasePending(ws, tx, now, resp);
- await gatherRecoupPending(ws, tx, now, resp);
- await gatherBackupPending(ws, tx, now, resp);
- await gatherPeerPushInitiationPending(ws, tx, now, resp);
- await gatherPeerPullInitiationPending(ws, tx, now, resp);
- await gatherPeerPullDebitPending(ws, tx, now, resp);
- await gatherPeerPushCreditPending(ws, tx, now, resp);
- return resp;
- });
-
- resp.pendingOperations.sort((a, b) => {
- let prioA = taskPrio[a.type];
- let prioB = taskPrio[b.type];
- return Math.sign(prioA - prioB);
- });
-
- return resp;
-}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
deleted file mode 100644
index 17ac54cfb..000000000
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ /dev/null
@@ -1,1449 +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,
- AgeCommitment,
- AgeRestriction,
- AmountJson,
- Amounts,
- amountToPretty,
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
- CoinPublicKeyString,
- CoinRefreshRequest,
- CoinStatus,
- DenominationInfo,
- DenomKeyType,
- Duration,
- durationFromSpec,
- durationMul,
- encodeCrock,
- ExchangeMeltRequest,
- ExchangeProtocolVersion,
- ExchangeRefreshRevealRequest,
- fnutil,
- getErrorDetailFromException,
- getRandomBytes,
- HashCodeString,
- HttpStatusCode,
- j2s,
- Logger,
- makeErrorDetail,
- NotificationType,
- RefreshGroupId,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionState,
- TransactionType,
- URL,
-} from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrThrow,
- readUnexpectedResponseDetails,
-} from "@gnu-taler/taler-util/http";
-import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
-import {
- DerivedRefreshSession,
- RefreshNewDenomInfo,
-} from "../crypto/cryptoTypes.js";
-import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- RefreshCoinStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
- RefreshReasonDetails,
- WalletStoresV1,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- isWithdrawableDenom,
- PendingTaskType,
- RefreshSessionRecord,
- timestampPreciseToDb,
- timestampProtocolFromDb,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
-import {
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
- TaskRunResult,
- TaskRunResultType,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("refresh.ts");
-
-/**
- * Get the amount that we lose when refreshing a coin of the given denomination
- * with a certain amount left.
- *
- * If the amount left is zero, then the refresh cost
- * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
- * the right denominations), then the cost is the full amount left.
- *
- * Considers refresh fees, withdrawal fees after refresh and amounts too small
- * to refresh.
- */
-export function getTotalRefreshCost(
- denoms: DenominationRecord[],
- refreshedDenom: DenominationInfo,
- amountLeft: AmountJson,
- denomselAllowLate: boolean,
-): AmountJson {
- const withdrawAmount = Amounts.sub(
- amountLeft,
- refreshedDenom.feeRefresh,
- ).amount;
- const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
- const withdrawDenoms = selectWithdrawalDenominations(
- withdrawAmount,
- denoms,
- denomselAllowLate,
- );
- const resultingAmount = Amounts.add(
- Amounts.zeroOfCurrency(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
- const allFinal = fnutil.all(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
- );
- const anyFailed = fnutil.any(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Failed,
- );
- if (allFinal) {
- if (anyFailed) {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Failed;
- } else {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Finished;
- }
- return { final: true };
- }
- return { final: false };
-}
-
-/**
- * Create a refresh session for one particular coin inside a refresh group.
- *
- * If the session already exists, return the existing one.
- *
- * If the session doesn't need to be created (refresh group gone or session already
- * finished), return undefined.
- */
-async function provideRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<RefreshSessionRecord | undefined> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
-
- const d = await ws.db
- .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (
- refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
- ) {
- return;
- }
- const existingRefreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await tx.coins.get(oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
- return { refreshGroup, coin, existingRefreshSession };
- });
-
- if (!d) {
- return undefined;
- }
-
- if (d.existingRefreshSession) {
- return d.existingRefreshSession;
- }
-
- const { refreshGroup, coin } = d;
-
- const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl);
-
- // FIXME: use helper functions from withdraw.ts
- // to update and filter withdrawable denoms.
-
- const { availableAmount, availableDenoms } = await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- exch.exchangeBaseUrl,
- coin.denomPubHash,
- );
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- // FIXME: Use denom groups instead of querying all denominations!
- const availableDenoms: DenominationRecord[] =
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exch.exchangeBaseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(
- refreshGroup.inputPerCoin[coinIndex],
- oldDenom.feeRefresh,
- ).amount;
- return { availableAmount, availableDenoms };
- });
-
- const newCoinDenoms = selectWithdrawalDenominations(
- availableAmount,
- availableDenoms,
- ws.config.testing.denomselAllowLate,
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- const transitionInfo = await ws.db
- .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- });
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- const sessionSecretSeed = encodeCrock(getRandomBytes(64));
-
- // Store refresh session for this coin in the database.
- const mySession = await ws.db
- .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const existingSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (existingSession) {
- return existingSession;
- }
- const newSession: RefreshSessionRecord = {
- coinIndex,
- refreshGroupId,
- norevealIndex: undefined,
- sessionSecretSeed: sessionSecretSeed,
- newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denomPubHash,
- })),
- amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
- };
- await tx.refreshSessions.put(newSession);
- return newSession;
- });
- logger.trace(
- `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- return mySession;
-}
-
-function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
- return Duration.fromSpec({
- seconds: 5,
- });
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations])
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
- });
-
- if (!d) {
- return;
- }
-
- const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `coins/${oldCoin.coinPub}/melt`,
- oldCoin.exchangeBaseUrl,
- );
-
- let maybeAch: HashCodeString | undefined;
- if (oldCoin.ageCommitmentProof) {
- maybeAch = AgeRestriction.hashCommitment(
- oldCoin.ageCommitmentProof.commitment,
- );
- }
-
- const meltReqBody: ExchangeMeltRequest = {
- coin_pub: oldCoin.coinPub,
- confirm_sig: derived.confirmSig,
- denom_pub_hash: oldCoin.denomPubHash,
- denom_sig: oldCoin.denomSig,
- rc: derived.hash,
- value_with_fee: Amounts.stringify(derived.meltValueWithFee),
- age_commitment_hash: maybeAch,
- };
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, meltReqBody, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (resp.status === HttpStatusCode.NotFound) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.refreshGroups,
- x.refreshSessions,
- x.coins,
- x.coinAvailability,
- ])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- throw Error(
- "db invariant failed: missing refresh session in database",
- );
- }
- refreshSession.lastError = errDetails;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- await tx.refreshSessions.put(refreshSession);
- const newTxState = computeRefreshTransactionState(rg);
- return {
- oldTxState,
- newTxState,
- };
- });
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- // Just log for better diagnostics here, error status
- // will be handled later.
- logger.error(
- `melt request for ${Amounts.stringify(
- derived.meltValueWithFee,
- )} failed in refresh group ${refreshGroupId} due to conflict`,
- );
-
- const historySig = await ws.cryptoApi.signCoinHistoryRequest({
- coinPriv: oldCoin.coinPriv,
- coinPub: oldCoin.coinPub,
- startOffset: 0,
- });
-
- const historyUrl = new URL(
- `coins/${oldCoin.coinPub}/history`,
- oldCoin.exchangeBaseUrl,
- );
-
- const historyResp = await ws.http.fetch(historyUrl.href, {
- method: "GET",
- headers: {
- "Taler-Coin-History-Signature": historySig.sig,
- },
- });
-
- const historyJson = await historyResp.json();
- logger.info(`coin history: ${j2s(historyJson)}`);
-
- // FIXME: Before failing and re-trying, analyse response and adjust amount
- }
-
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db
- .mktx((x) => [x.refreshGroups, x.refreshSessions])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- await tx.refreshSessions.put(rs);
- });
-}
-
-export async function assembleRefreshRevealRequest(args: {
- cryptoApi: TalerCryptoInterface;
- derived: DerivedRefreshSession;
- norevealIndex: number;
- oldCoinPub: CoinPublicKeyString;
- oldCoinPriv: string;
- newDenoms: {
- denomPubHash: string;
- count: number;
- }[];
- oldAgeCommitment?: AgeCommitment;
-}): Promise<ExchangeRefreshRevealRequest> {
- const {
- derived,
- norevealIndex,
- cryptoApi,
- oldCoinPriv,
- oldCoinPub,
- newDenoms,
- } = args;
- const privs = Array.from(derived.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = derived.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const newDenomsFlat: string[] = [];
- const linkSigs: string[] = [];
-
- for (let i = 0; i < newDenoms.length; i++) {
- const dsel = newDenoms[i];
- for (let j = 0; j < dsel.count; j++) {
- const newCoinIndex = linkSigs.length;
- const linkSig = await cryptoApi.signCoinLink({
- coinEv: planchets[newCoinIndex].coinEv,
- newDenomHash: dsel.denomPubHash,
- oldCoinPriv: oldCoinPriv,
- oldCoinPub: oldCoinPub,
- transferPub: derived.transferPubs[norevealIndex],
- });
- linkSigs.push(linkSig.sig);
- newDenomsFlat.push(dsel.denomPubHash);
- }
- }
-
- const req: ExchangeRefreshRevealRequest = {
- coin_evs: planchets.map((x) => x.coinEv),
- new_denoms_h: newDenomsFlat,
- transfer_privs: privs,
- transfer_pub: derived.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- old_age_commitment: args.oldAgeCommitment?.publicKeys,
- };
- return req;
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
- );
- const d = await ws.db
- .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations])
- .runReadOnly(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- };
- });
-
- if (!d) {
- return;
- }
-
- const {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- newCoinDenoms,
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `refreshes/${derived.hash}/reveal`,
- oldCoin.exchangeBaseUrl,
- );
-
- const req = await assembleRefreshRevealRequest({
- cryptoApi: ws.cryptoApi,
- derived,
- newDenoms: newCoinDenoms,
- norevealIndex: norevealIndex,
- oldCoinPriv: oldCoin.coinPriv,
- oldCoinPub: oldCoin.coinPub,
- oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
- });
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, req, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const ncd = newCoinDenoms[i];
- for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
- const newCoinIndex = coins.length;
- const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
- if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
- throw Error("cipher unsupported");
- }
- const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
- const denomSig = await ws.cryptoApi.unblindDenominationSignature({
- planchet: {
- blindingKey: pc.blindingKey,
- denomPub: ncd.denomPub,
- },
- evSig,
- });
- const coin: CoinRecord = {
- blindingKey: pc.blindingKey,
- coinPriv: pc.coinPriv,
- coinPub: pc.coinPub,
- denomPubHash: ncd.denomPubHash,
- denomSig,
- exchangeBaseUrl: oldCoin.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- refreshGroupId,
- oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
- },
- sourceTransactionId: transactionId,
- coinEvHash: pc.coinEvHash,
- maxAge: pc.maxAge,
- ageCommitmentProof: pc.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- coins.push(coin);
- }
- }
-
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.coins,
- x.denominations,
- x.coinAvailability,
- x.refreshGroups,
- x.refreshSessions,
- ])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- logger.warn("no refresh session found");
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
- for (const coin of coins) {
- await makeCoinAvailable(ws, tx, coin);
- }
- await makeCoinsVisible(ws, tx, transactionId);
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- logger.trace("refresh finished (end of reveal)");
-}
-
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- options: Record<string, never> = {},
-): Promise<TaskRunResult> {
- logger.trace(`processing refresh group ${refreshGroupId}`);
-
- const refreshGroup = await ws.db
- .mktx((x) => [x.refreshGroups])
- .runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId));
- if (!refreshGroup) {
- return TaskRunResult.finished();
- }
- if (refreshGroup.timestampFinished) {
- return TaskRunResult.finished();
- }
- // Process refresh sessions of the group in parallel.
- logger.trace("processing refresh sessions for $ old coins");
- let errors: TalerErrorDetail[] = [];
- let inShutdown = false;
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i).catch((x) => {
- if (x instanceof CryptoApiStoppedError) {
- inShutdown = true;
- logger.info(
- "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
- );
- return;
- }
- if (x instanceof TalerError) {
- logger.warn("process refresh session got exception (TalerError)");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- logger.warn(`error detail: ${j2s(x.errorDetail)}`);
- } else {
- logger.warn("process refresh session got exception");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- }
- errors.push(getErrorDetailFromException(x));
- }),
- );
- try {
- logger.info("waiting for refreshes");
- await Promise.all(ps);
- logger.info("refresh group finished");
- } catch (e) {
- logger.warn("process refresh sessions got exception");
- logger.warn(`exception: ${e}`);
- }
- if (inShutdown) {
- return TaskRunResult.pending();
- }
- if (errors.length > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
- {
- numErrors: errors.length,
- errors: errors.slice(0, 5),
- },
- ),
- };
- }
-
- return TaskRunResult.pending();
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let { refreshGroup, refreshSession } = await ws.db
- .mktx((x) => [x.refreshGroups, x.refreshSessions])
- .runReadOnly(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- return {
- refreshGroup: rg,
- refreshSession: rs,
- };
- });
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
- return;
- }
- if (!refreshSession) {
- refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex);
- }
- if (!refreshSession) {
- // We tried to create the refresh session, but didn't get a result back.
- // This means that either the session is finished, or that creating
- // one isn't necessary.
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-export interface RefreshOutputInfo {
- outputPerCoin: AmountJson[];
-}
-
-export async function calculateRefreshOutput(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- }>,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
-): Promise<RefreshOutputInfo> {
- const estimatedOutputPerCoin: AmountJson[] = [];
-
- const denomsPerExchange: Record<string, DenominationRecord[]> = {};
-
- // FIXME: Use denom groups instead of querying all denominations!
- const getDenoms = async (
- exchangeBaseUrl: string,
- ): Promise<DenominationRecord[]> => {
- if (denomsPerExchange[exchangeBaseUrl]) {
- return denomsPerExchange[exchangeBaseUrl];
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- exchangeBaseUrl,
- currency,
- );
- denomsPerExchange[exchangeBaseUrl] = allDenoms;
- return allDenoms;
- };
-
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- const refreshAmount = ocp.amount;
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(
- denoms,
- denom,
- Amounts.parseOrThrow(refreshAmount),
- ws.config.testing.denomselAllowLate,
- );
- const output = Amounts.sub(refreshAmount, cost).amount;
- estimatedOutputPerCoin.push(output);
- }
-
- return {
- outputPerCoin: estimatedOutputPerCoin,
- };
-}
-
-async function applyRefresh(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- }>,
- oldCoinPubs: CoinRefreshRequest[],
- refreshGroupId: string,
-): Promise<void> {
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- switch (coin.status) {
- case CoinStatus.Dormant:
- break;
- case CoinStatus.Fresh: {
- coin.status = CoinStatus.Dormant;
- const coinAv = await tx.coinAvailability.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- coin.maxAge,
- ]);
- checkDbInvariant(!!coinAv);
- checkDbInvariant(coinAv.freshCoinCount > 0);
- coinAv.freshCoinCount--;
- await tx.coinAvailability.put(coinAv);
- break;
- }
- case CoinStatus.FreshSuspended: {
- // For suspended coins, we don't have to adjust coin
- // availability, as they are not counted as available.
- coin.status = CoinStatus.Dormant;
- break;
- }
- default:
- assertUnreachable(coin.status);
- }
- if (!coin.spendAllocation) {
- coin.spendAllocation = {
- amount: Amounts.stringify(ocp.amount),
- // id: `txn:refresh:${refreshGroupId}`,
- id: constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- }),
- };
- }
- await tx.coins.put(coin);
- }
-}
-
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coinAvailability: typeof WalletStoresV1.coinAvailability;
- }>,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
- reason: RefreshReason,
- reasonDetails?: RefreshReasonDetails,
-): Promise<RefreshGroupId> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
-
- const estimatedOutputPerCoin = outInfo.outputPerCoin;
-
- await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
-
- const refreshGroup: RefreshGroupRecord = {
- operationStatus: RefreshOperationStatus.Pending,
- currency,
- timestampFinished: undefined,
- statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- reasonDetails,
- reason,
- refreshGroupId,
- inputPerCoin: oldCoinPubs.map((x) => x.amount),
- expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
- Amounts.stringify(x),
- ),
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
-
- if (oldCoinPubs.length == 0) {
- logger.warn("created refresh group with zero coins");
- refreshGroup.timestampFinished = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- refreshGroup.operationStatus = RefreshOperationStatus.Finished;
- }
-
- await tx.refreshGroups.put(refreshGroup);
-
- logger.trace(`created refresh group ${refreshGroupId}`);
-
- return {
- refreshGroupId,
- };
-}
-
-/**
- * Timestamp after which the wallet would do the next check for an auto-refresh.
- */
-function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireWithdraw),
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireDeposit),
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.75);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-/**
- * Timestamp after which the wallet would do an auto-refresh.
- */
-export function getAutoRefreshExecuteThreshold(d: {
- stampExpireWithdraw: TalerProtocolTimestamp;
- stampExpireDeposit: TalerProtocolTimestamp;
-}): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireWithdraw,
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireDeposit,
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.5);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-function getAutoRefreshExecuteThresholdForDenom(
- d: DenominationRecord,
-): AbsoluteTime {
- return getAutoRefreshExecuteThreshold({
- stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
- stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
- });
-}
-
-export async function autoRefresh(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<TaskRunResult> {
- logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
-
- // We must make sure that the exchange is up-to-date so that
- // can refresh into new denominations.
- await fetchFreshExchange(ws, exchangeBaseUrl);
-
- let minCheckThreshold = AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- durationFromSpec({ days: 1 }),
- );
- await ws.db
- .mktx((x) => [
- x.coins,
- x.denominations,
- x.coinAvailability,
- x.refreshGroups,
- x.exchanges,
- ])
- .runReadWrite(async (tx) => {
- const exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange || !exchange.detailsPointer) {
- return;
- }
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchangeBaseUrl)
- .toArray();
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const coin of coins) {
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- const denom = await tx.denominations.get([
- exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination not in database");
- continue;
- }
- const executeThreshold = getAutoRefreshExecuteThresholdForDenom(denom);
- if (AbsoluteTime.isExpired(executeThreshold)) {
- refreshCoins.push({
- coinPub: coin.coinPub,
- amount: denom.value,
- });
- } else {
- const checkThreshold = getAutoRefreshCheckThreshold(denom);
- minCheckThreshold = AbsoluteTime.min(
- minCheckThreshold,
- checkThreshold,
- );
- }
- }
- if (refreshCoins.length > 0) {
- const res = await createRefreshGroup(
- ws,
- tx,
- exchange.detailsPointer?.currency,
- refreshCoins,
- RefreshReason.Scheduled,
- );
- logger.trace(
- `created refresh group for auto-refresh (${res.refreshGroupId})`,
- );
- }
- // logger.trace(
- // `current wallet time: ${AbsoluteTime.toIsoString(AbsoluteTime.now())}`,
- // );
- logger.trace(
- `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`,
- );
- exchange.nextRefreshCheckStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
- );
- await tx.exchanges.put(exchange);
- });
- return TaskRunResult.finished();
-}
-
-export function computeRefreshTransactionState(
- rg: RefreshGroupRecord,
-): TransactionState {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return {
- major: TransactionMajorState.Done,
- };
- case RefreshOperationStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case RefreshOperationStatus.Pending:
- return {
- major: TransactionMajorState.Pending,
- };
- case RefreshOperationStatus.Suspended:
- return {
- major: TransactionMajorState.Suspended,
- };
- }
-}
-
-export function computeRefreshTransactionActions(
- rg: RefreshGroupRecord,
-): TransactionAction[] {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Failed:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Pending:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case RefreshOperationStatus.Suspended:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
-
-export async function suspendRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
- let res = await ws.db
- .mktx((x) => [x.refreshGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return undefined;
- case RefreshOperationStatus.Pending: {
- dg.operationStatus = RefreshOperationStatus.Suspended;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- case RefreshOperationStatus.Suspended:
- return undefined;
- }
- return undefined;
- });
- if (res) {
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
- oldTxState: res.oldTxState,
- newTxState: res.newTxState,
- });
- }
-}
-
-export async function resumeRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return;
- case RefreshOperationStatus.Pending: {
- return;
- }
- case RefreshOperationStatus.Suspended:
- dg.operationStatus = RefreshOperationStatus.Pending;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- throw Error("action cancel-aborting not allowed on refreshes");
-}
-
-export async function abortRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.refreshGroups])
- .runReadWrite(async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- let newStatus: RefreshOperationStatus | undefined;
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- break;
- case RefreshOperationStatus.Pending:
- case RefreshOperationStatus.Suspended:
- newStatus = RefreshOperationStatus.Failed;
- break;
- case RefreshOperationStatus.Failed:
- break;
- default:
- assertUnreachable(dg.operationStatus);
- }
- if (newStatus) {
- dg.operationStatus = newStatus;
- await tx.refreshGroups.put(dg);
- }
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts
deleted file mode 100644
index 79beb6432..000000000
--- a/packages/taler-wallet-core/src/operations/reward.ts
+++ /dev/null
@@ -1,644 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AcceptTipResponse,
- AgeRestriction,
- Amounts,
- BlindedDenominationSignature,
- codecForMerchantTipResponseV2,
- codecForRewardPickupGetResponse,
- CoinStatus,
- DenomKeyType,
- encodeCrock,
- getRandomBytes,
- j2s,
- Logger,
- NotificationType,
- parseRewardUri,
- PrepareTipResult,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TipPlanchetDetail,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
-} from "@gnu-taler/taler-util";
-import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- RewardRecord,
- RewardRecordStatus,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../db.js";
-import { makeErrorDetail } from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- getHttpResponseErrorDetails,
- readSuccessResponseJsonOrThrow,
-} from "@gnu-taler/taler-util/http";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
- TaskRunResult,
- TaskRunResultType,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- getCandidateWithdrawalDenoms,
- getExchangeWithdrawalInfo,
- updateWithdrawalDenoms,
-} from "./withdraw.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- stopLongpolling,
-} from "./transactions.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-
-const logger = new Logger("operations/tip.ts");
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a reward.
- */
-export function computeRewardTransactionStatus(
- tipRecord: RewardRecord,
-): TransactionState {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RewardRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RewardRecordStatus.PendingPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.DialogAccept:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case RewardRecordStatus.SuspendedPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export function computeTipTransactionActions(
- tipRecord: RewardRecord,
-): TransactionAction[] {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case RewardRecordStatus.PendingPickup:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case RewardRecordStatus.SuspendedPickup:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case RewardRecordStatus.DialogAccept:
- return [TransactionAction.Abort];
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export async function prepareTip(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- const res = parseRewardUri(talerTipUri);
- if (!res) {
- throw Error("invalid taler://tip URI");
- }
-
- let tipRecord = await ws.db
- .mktx((x) => [x.rewards])
- .runReadOnly(async (tx) => {
- return tx.rewards.indexes.byMerchantTipIdAndBaseUrl.get([
- res.merchantRewardId,
- res.merchantBaseUrl,
- ]);
- });
-
- if (!tipRecord) {
- const tipStatusUrl = new URL(
- `rewards/${res.merchantRewardId}`,
- res.merchantBaseUrl,
- );
- logger.trace("checking tip status from", tipStatusUrl.href);
- const merchantResp = await ws.http.fetch(tipStatusUrl.href);
- const tipPickupStatus = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForRewardPickupGetResponse(),
- );
- logger.trace(`status ${j2s(tipPickupStatus)}`);
-
- const amount = Amounts.parseOrThrow(tipPickupStatus.reward_amount);
- const currency = amount.currency;
-
- logger.trace("new tip, creating tip record");
- await fetchFreshExchange(ws, tipPickupStatus.exchange_url);
-
- //FIXME: is this needed? withdrawDetails is not used
- // * if the intention is to update the exchange information in the database
- // maybe we can use another name. `get` seems like a pure-function
- const withdrawDetails = await getExchangeWithdrawalInfo(
- ws,
- tipPickupStatus.exchange_url,
- amount,
- undefined,
- );
-
- const walletTipId = encodeCrock(getRandomBytes(32));
- await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- tipPickupStatus.exchange_url,
- currency,
- );
- const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
-
- const secretSeed = encodeCrock(getRandomBytes(64));
- const denomSelUid = encodeCrock(getRandomBytes(32));
-
- const newTipRecord: RewardRecord = {
- walletRewardId: walletTipId,
- acceptedTimestamp: undefined,
- status: RewardRecordStatus.DialogAccept,
- rewardAmountRaw: Amounts.stringify(amount),
- rewardExpiration: timestampProtocolToDb(tipPickupStatus.expiration),
- exchangeBaseUrl: tipPickupStatus.exchange_url,
- next_url: tipPickupStatus.next_url,
- merchantBaseUrl: res.merchantBaseUrl,
- createdTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- merchantRewardId: res.merchantRewardId,
- rewardAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
- denomsSel: selectedDenoms,
- pickedUpTimestamp: undefined,
- secretSeed,
- denomSelUid,
- };
- await ws.db
- .mktx((x) => [x.rewards])
- .runReadWrite(async (tx) => {
- await tx.rewards.put(newTipRecord);
- });
- tipRecord = newTipRecord;
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: tipRecord.walletRewardId,
- });
-
- const tipStatus: PrepareTipResult = {
- accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
- rewardAmountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- expirationTimestamp: timestampProtocolFromDb(tipRecord.rewardExpiration),
- rewardAmountEffective: Amounts.stringify(tipRecord.rewardAmountEffective),
- walletRewardId: tipRecord.walletRewardId,
- transactionId,
- };
-
- return tipStatus;
-}
-
-export async function processTip(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<TaskRunResult> {
- const tipRecord = await ws.db
- .mktx((x) => [x.rewards])
- .runReadOnly(async (tx) => {
- return tx.rewards.get(walletTipId);
- });
- if (!tipRecord) {
- return TaskRunResult.finished();
- }
-
- switch (tipRecord.status) {
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Done:
- case RewardRecordStatus.SuspendedPickup:
- return TaskRunResult.finished();
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletTipId,
- });
-
- const denomsForWithdraw = tipRecord.denomsSel;
-
- const planchets: DerivedTipPlanchet[] = [];
- // Planchets in the form that the merchant expects
- const planchetsDetail: TipPlanchetDetail[] = [];
- const denomForPlanchet: { [index: number]: DenominationRecord } = [];
-
- for (const dh of denomsForWithdraw.selectedDenoms) {
- const denom = await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- return tx.denominations.get([
- tipRecord.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- });
- checkDbInvariant(!!denom, "denomination should be in database");
- for (let i = 0; i < dh.count; i++) {
- const deriveReq = {
- denomPub: denom.denomPub,
- planchetIndex: planchets.length,
- secretSeed: tipRecord.secretSeed,
- };
- logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`);
- const p = await ws.cryptoApi.createTipPlanchet(deriveReq);
- logger.trace(`derive result: ${j2s(p)}`);
- denomForPlanchet[planchets.length] = denom;
- planchets.push(p);
- planchetsDetail.push({
- coin_ev: p.coinEv,
- denom_pub_hash: denom.denomPubHash,
- });
- }
- }
-
- const tipStatusUrl = new URL(
- `rewards/${tipRecord.merchantRewardId}/pickup`,
- tipRecord.merchantBaseUrl,
- );
-
- const req = { planchets: planchetsDetail };
- logger.trace(`sending tip request: ${j2s(req)}`);
- const merchantResp = await ws.http.fetch(tipStatusUrl.href, {
- method: "POST",
- body: req,
- });
-
- logger.trace(`got tip response, status ${merchantResp.status}`);
-
- // FIXME: Why do we do this?
- if (
- (merchantResp.status >= 500 && merchantResp.status <= 599) ||
- merchantResp.status === 424
- ) {
- logger.trace(`got transient tip error`);
- // FIXME: wrap in another error code that indicates a transient error
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- getHttpResponseErrorDetails(merchantResp),
- "tip pickup failed (transient)",
- ),
- };
- }
- let blindedSigs: BlindedDenominationSignature[] = [];
-
- const response = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForMerchantTipResponseV2(),
- );
- blindedSigs = response.blind_sigs.map((x) => x.blind_sig);
-
- if (blindedSigs.length !== planchets.length) {
- throw Error("number of tip responses does not match requested planchets");
- }
-
- const newCoinRecords: CoinRecord[] = [];
-
- for (let i = 0; i < blindedSigs.length; i++) {
- const blindedSig = blindedSigs[i];
-
- const denom = denomForPlanchet[i];
- checkLogicInvariant(!!denom);
- const planchet = planchets[i];
- checkLogicInvariant(!!planchet);
-
- if (denom.denomPub.cipher !== DenomKeyType.Rsa) {
- throw Error("unsupported cipher");
- }
-
- if (blindedSig.cipher !== DenomKeyType.Rsa) {
- throw Error("unsupported cipher");
- }
-
- const denomSigRsa = await ws.cryptoApi.rsaUnblind({
- bk: planchet.blindingKey,
- blindedSig: blindedSig.blinded_rsa_signature,
- pk: denom.denomPub.rsa_public_key,
- });
-
- const isValid = await ws.cryptoApi.rsaVerify({
- hm: planchet.coinPub,
- pk: denom.denomPub.rsa_public_key,
- sig: denomSigRsa.sig,
- });
-
- if (!isValid) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_REWARD_COIN_SIGNATURE_INVALID,
- {},
- "invalid signature from the exchange (via merchant reward) after unblinding",
- ),
- };
- }
-
- newCoinRecords.push({
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- coinSource: {
- type: CoinSourceType.Reward,
- coinIndex: i,
- walletRewardId: walletTipId,
- },
- sourceTransactionId: transactionId,
- denomPubHash: denom.denomPubHash,
- denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig },
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinEvHash: planchet.coinEvHash,
- maxAge: AgeRestriction.AGE_UNRESTRICTED,
- ageCommitmentProof: planchet.ageCommitmentProof,
- spendAllocation: undefined,
- });
- }
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.rewards])
- .runReadWrite(async (tx) => {
- const tr = await tx.rewards.get(walletTipId);
- if (!tr) {
- return;
- }
- if (tr.status !== RewardRecordStatus.PendingPickup) {
- return;
- }
- const oldTxState = computeRewardTransactionStatus(tr);
- tr.pickedUpTimestamp = timestampPreciseToDb(TalerPreciseTimestamp.now());
- tr.status = RewardRecordStatus.Done;
- await tx.rewards.put(tr);
- const newTxState = computeRewardTransactionStatus(tr);
- for (const cr of newCoinRecords) {
- await makeCoinAvailable(ws, tx, cr);
- }
- await makeCoinsVisible(ws, tx, transactionId);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- return TaskRunResult.finished();
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<AcceptTipResponse> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletTipId,
- });
- const dbRes = await ws.db
- .mktx((x) => [x.rewards])
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.rewards.get(walletTipId);
- if (!tipRecord) {
- logger.error("tip not found");
- return;
- }
- if (tipRecord.status != RewardRecordStatus.DialogAccept) {
- logger.warn("Unable to accept tip in the current state");
- return { tipRecord };
- }
- const oldTxState = computeRewardTransactionStatus(tipRecord);
- tipRecord.acceptedTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- tipRecord.status = RewardRecordStatus.PendingPickup;
- await tx.rewards.put(tipRecord);
- const newTxState = computeRewardTransactionStatus(tipRecord);
- return { tipRecord, transitionInfo: { oldTxState, newTxState } };
- });
-
- if (!dbRes) {
- throw Error("tip not found");
- }
-
- notifyTransition(ws, transactionId, dbRes.transitionInfo);
-
- const tipRecord = dbRes.tipRecord;
-
- return {
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletTipId,
- }),
- next_url: tipRecord.next_url,
- };
-}
-
-export async function suspendRewardTransaction(
- ws: InternalWalletState,
- walletRewardId: string,
-): Promise<void> {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: walletRewardId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletRewardId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.rewards])
- .runReadWrite(async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.SuspendedPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- break;
- case RewardRecordStatus.PendingPickup:
- newStatus = RewardRecordStatus.SuspendedPickup;
- break;
-
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumeTipTransaction(
- ws: InternalWalletState,
- walletRewardId: string,
-): Promise<void> {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: walletRewardId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletRewardId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.rewards])
- .runReadWrite(async (tx) => {
- const rewardRec = await tx.rewards.get(walletRewardId);
- if (!rewardRec) {
- logger.warn(`transaction reward ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (rewardRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.PendingPickup;
- break;
- default:
- assertUnreachable(rewardRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(rewardRec);
- rewardRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(rewardRec);
- await tx.rewards.put(rewardRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failTipTransaction(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<void> {
- // We don't have an "aborting" state, so this should never happen!
- throw Error("can't run cance-aborting on tip transaction");
-}
-
-export async function abortTipTransaction(
- ws: InternalWalletState,
- walletRewardId: string,
-): Promise<void> {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: walletRewardId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: walletRewardId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.rewards])
- .runReadWrite(async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.DialogAccept:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.Aborted;
- break;
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
deleted file mode 100644
index 392b3753d..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ /dev/null
@@ -1,2766 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2021 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- AgeRestriction,
- AmountJson,
- AmountLike,
- AmountString,
- Amounts,
- BankWithdrawDetails,
- CancellationToken,
- CoinStatus,
- CurrencySpecification,
- DenomKeyType,
- DenomSelectionState,
- Duration,
- ExchangeBatchWithdrawRequest,
- ExchangeListItem,
- ExchangeWireAccount,
- ExchangeWithdrawBatchResponse,
- ExchangeWithdrawRequest,
- ExchangeWithdrawResponse,
- ExchangeWithdrawalDetails,
- ForcedDenomSel,
- HttpStatusCode,
- LibtoolVersion,
- Logger,
- NotificationType,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- UnblindedSignature,
- WithdrawUriInfoResponse,
- WithdrawalExchangeAccountDetails,
- addPaytoQueryParams,
- canonicalizeBaseUrl,
- codecForAny,
- codecForBankWithdrawalOperationPostResponse,
- codecForCashinConversionResponse,
- codecForConversionBankConfig,
- codecForExchangeWithdrawBatchResponse,
- codecForIntegrationBankConfig,
- codecForReserveStatus,
- codecForWalletKycUuid,
- codecForWithdrawOperationStatusResponse,
- encodeCrock,
- getErrorDetailFromException,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parseWithdrawUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- HttpResponse,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- DenominationVerificationStatus,
- KycPendingInfo,
- PlanchetRecord,
- PlanchetStatus,
- WalletStoresV1,
- WgInfo,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- ExchangeDetailsRecord,
- ExchangeEntryDbRecordStatus,
- Wallet,
- isWithdrawableDenom,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
- makeExchangeListItem,
- runLongpollAsync,
-} from "../operations/common.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- selectForcedWithdrawalDenominations,
- selectWithdrawalDenominations,
-} from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- DbAccess,
- GetReadOnlyAccess,
- GetReadWriteAccess,
-} from "../util/query.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-import {
- getExchangeDetails,
- getExchangePaytoUri,
- fetchFreshExchange,
- ReadyExchangeSummary,
-} from "./exchanges.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
- stopLongpolling,
-} from "./transactions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("operations/withdraw.ts");
-
-export async function suspendWithdrawalTransaction(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
- break;
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
- break;
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
- break;
- case WithdrawalGroupStatus.PendingKyc:
- newStatus = WithdrawalGroupStatus.SuspendedKyc;
- break;
- case WithdrawalGroupStatus.PendingAml:
- newStatus = WithdrawalGroupStatus.SuspendedAml;
- break;
- default:
- logger.warn(
- `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumeWithdrawalTransaction(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedReady:
- newStatus = WithdrawalGroupStatus.PendingReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
- break;
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- newStatus = WithdrawalGroupStatus.PendingAml;
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- newStatus = WithdrawalGroupStatus.PendingKyc;
- break;
- default:
- logger.warn(
- `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortWithdrawalTransaction(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.PendingKyc:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.AbortedExchange;
- break;
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- // No transition needed, but not an error
- break;
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.FailedAbortingBank:
- // Not allowed
- throw Error("abort not allowed in current state");
- break;
- default:
- assertUnreachable(wg.status);
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failWithdrawalTransaction(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- stopLongpolling(ws, taskId);
- const stateUpdate = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.FailedAbortingBank;
- break;
- default:
- break;
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, stateUpdate);
-}
-
-export function computeWithdrawalTransactionStatus(
- wgRecord: WithdrawalGroupRecord,
-): TransactionState {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case WithdrawalGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.WithdrawCoins,
- };
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.AbortingBank:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.SuspendedReady: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.WithdrawCoins,
- };
- }
- case WithdrawalGroupStatus.PendingAml: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.PendingKyc: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedAml: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedKyc: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.FailedAbortingBank:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- case WithdrawalGroupStatus.AbortedExchange:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Exchange,
- };
-
- case WithdrawalGroupStatus.AbortedBank:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Bank,
- };
- }
-}
-
-export function computeWithdrawalTransactionActions(
- wgRecord: WithdrawalGroupRecord,
-): TransactionAction[] {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.Done:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingReady:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.AbortingBank:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.FailedAbortingBank:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedExchange:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedBank:
- return [TransactionAction.Delete];
- }
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- *
- * FIXME: Move into bank client.
- */
-export async function getBankWithdrawalInfo(
- http: HttpRequestLibrary,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl);
-
- const configResp = await http.fetch(configReqUrl.href);
- const config = await readSuccessResponseJsonOrThrow(
- configResp,
- codecForIntegrationBankConfig(),
- );
-
- const versionRes = LibtoolVersion.compare(
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- config.version,
- );
- if (versionRes?.compatible != true) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- bankProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- "bank integration protocol version not compatible with wallet",
- );
- }
-
- const reqUrl = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
-
- logger.info(`bank withdrawal status URL: ${reqUrl.href}}`);
-
- const resp = await http.fetch(reqUrl.href);
- const status = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- logger.info(`bank withdrawal operation status: ${j2s(status)}`);
-
- return {
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- selectionDone: status.selection_done,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- transferDone: status.transfer_done,
- wireTypes: status.wire_types,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-export async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- return await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
- });
-}
-
-export async function getCandidateWithdrawalDenomsTx(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.denominations }>,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- // FIXME: Use denom groups instead of querying all denominations!
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
- return allDenoms
- .filter((d) => d.currency === currency)
- .filter((d) => isWithdrawableDenom(d, ws.config.testing.denomselAllowLate));
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- coinIdx: number,
-): Promise<void> {
- let planchet = await ws.db
- .mktx((x) => [x.planchets])
- .runReadOnly(async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (planchet) {
- return;
- }
- let ci = 0;
- 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) {
- maybeDenomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!maybeDenomPubHash) {
- throw Error("invariant violated");
- }
- const denomPubHash = maybeDenomPubHash;
-
- const denom = await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- return ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash,
- );
- });
- checkDbInvariant(!!denom);
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
- reservePriv: withdrawalGroup.reservePriv,
- reservePub: withdrawalGroup.reservePub,
- value: Amounts.parseOrThrow(denom.value),
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- restrictAge: withdrawalGroup.restrictAge,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- denomPubHash: r.denomPubHash,
- planchetStatus: PlanchetStatus.Pending,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- ageCommitmentProof: r.ageCommitmentProof,
- lastError: undefined,
- };
- await ws.db
- .mktx((x) => [x.planchets])
- .runReadWrite(async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
-}
-
-interface WithdrawalRequestBatchArgs {
- coinStartIndex: number;
-
- batchSize: number;
-}
-
-interface WithdrawalBatchResult {
- coinIdxs: number[];
- batchResp: ExchangeWithdrawBatchResponse;
-}
-enum AmlStatus {
- normal = 0,
- pending = 1,
- fronzen = 2,
-}
-
-/**
- * Transition a withdrawal transaction with a (new) KYC URL.
- *
- * Emit a notification for the (self-)transition.
- */
-async function transitionKycUrlUpdate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- kycUrl: string,
-): Promise<void> {
- let notificationKycUrl: string | undefined = undefined;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.planchets, x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycUrl = kycUrl;
- notificationKycUrl = kycUrl;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- });
- if (transitionInfo) {
- // Always notify, even on self-transition, as the KYC URL might have changed.
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData: notificationKycUrl,
- });
- }
- ws.workAvailable.trigger();
-}
-
-async function handleKycRequired(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- resp: HttpResponse,
- startIdx: number,
- requestCoinIdxs: number[],
-): Promise<void> {
- logger.info("withdrawal requires KYC");
- const respJson = await resp.json();
- const uuidResp = codecForWalletKycUuid().decode(respJson);
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const userType = "individual";
- const kycInfo: KycPendingInfo = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
- let kycUrl: string;
- let amlStatus: AmlStatus | undefined;
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return;
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- kycUrl = kycStatus.kyc_url;
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- amlStatus = kycStatus.aml_status;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-
- let notificationKycUrl: string | undefined = undefined;
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.planchets, x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- for (let i = startIdx; i < requestCoinIdxs.length; i++) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- requestCoinIdxs[i],
- ]);
- if (!planchet) {
- continue;
- }
- planchet.planchetStatus = PlanchetStatus.KycRequired;
- await tx.planchets.put(planchet);
- }
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycPending = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- wg2.kycUrl = kycUrl;
- wg2.status =
- amlStatus === AmlStatus.normal || amlStatus === undefined
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === AmlStatus.pending
- ? WithdrawalGroupStatus.PendingAml
- : amlStatus === AmlStatus.fronzen
- ? WithdrawalGroupStatus.SuspendedAml
- : assertUnreachable(amlStatus);
-
- notificationKycUrl = kycUrl;
-
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- });
- notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeBatchRequest(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- args: WithdrawalRequestBatchArgs,
-): Promise<WithdrawalBatchResult> {
- const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
- logger.info(
- `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
- );
-
- const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
- // Indices of coins that are included in the batch request
- const requestCoinIdxs: number[] = [];
-
- await ws.db
- .mktx((x) => [
- x.withdrawalGroups,
- x.planchets,
- x.exchanges,
- x.denominations,
- ])
- .runReadOnly(async (tx) => {
- for (
- let coinIdx = args.coinStartIndex;
- coinIdx < args.coinStartIndex + args.batchSize &&
- coinIdx < wgContext.numPlanchets;
- coinIdx++
- ) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- continue;
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
-
- if (!denom) {
- logger.error("db inconsistent: denom for planchet not found");
- continue;
- }
-
- const planchetReq: ExchangeWithdrawRequest = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- batchReq.planchets.push(planchetReq);
- requestCoinIdxs.push(coinIdx);
- }
- });
-
- if (batchReq.planchets.length == 0) {
- logger.warn("empty withdrawal batch");
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-
- async function storeCoinError(e: any, coinIdx: number): Promise<void> {
- const errDetail = getErrorDetailFromException(e);
- logger.trace("withdrawal request failed", e);
- logger.trace(String(e));
- await ws.db
- .mktx((x) => [x.planchets])
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetail;
- await tx.planchets.put(planchet);
- });
- }
-
- // FIXME: handle individual error codes better!
-
- const reqUrl = new URL(
- `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
- withdrawalGroup.exchangeBaseUrl,
- ).href;
-
- try {
- const resp = await ws.http.postJson(reqUrl, batchReq);
- if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
- await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWithdrawBatchResponse(),
- );
- return {
- coinIdxs: requestCoinIdxs,
- batchResp: r,
- };
- } catch (e) {
- await storeCoinError(e, requestCoinIdxs[0]);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- coinIdx: number,
- resp: ExchangeWithdrawResponse,
-): Promise<void> {
- const withdrawalGroup = wgContext.wgRecord;
- logger.trace(`checking and storing planchet idx=${coinIdx}`);
- const d = await ws.db
- .mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations])
- .runReadOnly(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return {
- planchet,
- denomInfo,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- };
- });
-
- if (!d) {
- return;
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
- });
-
- const { planchet, denomInfo } = d;
-
- const planchetDenomPub = denomInfo.denomPub;
- if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
- throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
- }
-
- let evSig = resp.ev_sig;
- if (!(evSig.cipher === DenomKeyType.Rsa)) {
- throw Error("unsupported cipher");
- }
-
- const denomSigRsa = await ws.cryptoApi.rsaUnblind({
- bk: planchet.blindingKey,
- blindedSig: evSig.blinded_rsa_signature,
- pk: planchetDenomPub.rsa_public_key,
- });
-
- const isValid = await ws.cryptoApi.rsaVerify({
- hm: planchet.coinPub,
- pk: planchetDenomPub.rsa_public_key,
- sig: denomSigRsa.sig,
- });
-
- if (!isValid) {
- await ws.db
- .mktx((x) => [x.planchets])
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- {},
- "invalid signature from the exchange after unblinding",
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- let denomSig: UnblindedSignature;
- if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
- denomSig = {
- cipher: planchetDenomPub.cipher,
- rsa_signature: denomSigRsa.sig,
- };
- } else {
- throw Error("unsupported cipher");
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: d.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: withdrawalGroup.reservePub,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- },
- sourceTransactionId: transactionId,
- maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
- ageCommitmentProof: planchet.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- wgContext.planchetsFinished.add(planchet.coinPub);
-
- // Check if this is the first time that the whole
- // withdrawal succeeded. If so, mark the withdrawal
- // group as finished.
- const success = await ws.db
- .mktx((x) => [
- x.coins,
- x.denominations,
- x.coinAvailability,
- x.withdrawalGroups,
- x.planchets,
- ])
- .runReadWrite(async (tx) => {
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- return false;
- }
- p.planchetStatus = PlanchetStatus.WithdrawalDone;
- p.lastError = undefined;
- await tx.planchets.put(p);
- await makeCoinAvailable(ws, tx, coin);
- return true;
- });
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-export async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db
- .mktx((x) => [x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
- });
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- exchangeDetails.currency,
- );
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (
- denom.verificationStatus === DenominationVerificationStatus.Unverified
- ) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- let valid = false;
- if (ws.config.testing.insecureTrustExchange) {
- valid = true;
- } else {
- const res = await ws.cryptoApi.isValidDenom({
- denom,
- masterPub: exchangeDetails.masterPublicKey,
- });
- valid = res.valid;
- }
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus =
- DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db
- .mktx((x) => [x.denominations])
- .runReadWrite(async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-/**
- * 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,
- cancellationToken: CancellationToken,
-): Promise<{ ready: boolean }> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- checkDbInvariant(!!withdrawalGroup);
- if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
- return { ready: true };
- }
- const reservePub = withdrawalGroup.reservePub;
-
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- withdrawalGroup.exchangeBaseUrl,
- );
- reserveUrl.searchParams.set("timeout_ms", "30000");
-
- logger.trace(`querying reserve status via ${reserveUrl.href}`);
-
- const resp = await ws.http.fetch(reserveUrl.href, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
-
- logger.trace(`reserve status code: HTTP ${resp.status}`);
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
-
- if (result.isError) {
- logger.trace(
- `got reserve status error, EC=${result.talerErrorResponse.code}`,
- );
- if (resp.status === HttpStatusCode.NotFound) {
- return { ready: false };
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- logger.trace(`got reserve status ${j2s(result.response)}`);
-
- const transitionResult = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return undefined;
- }
- const txStateOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.PendingReady;
- const txStateNew = computeWithdrawalTransactionStatus(wg);
- wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStateOld,
- newTxState: txStateNew,
- };
- });
-
- notifyTransition(ws, transactionId, transitionResult);
-
- return { ready: true };
-}
-
-enum BankStatusResultCode {
- Done = "done",
- Waiting = "waiting",
- Aborted = "aborted",
-}
-
-/**
- * Withdrawal context that is kept in-memory.
- *
- * Used to store some cached info during a withdrawal operation.
- */
-export interface WithdrawalGroupContext {
- numPlanchets: number;
- planchetsFinished: Set<string>;
-
- /**
- * Cached withdrawal group record from the database.
- */
- wgRecord: WithdrawalGroupRecord;
-}
-
-async function processWithdrawalGroupAbortingBank(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const wgInfo = withdrawalGroup.wgInfo;
- if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
- throw Error("invalid state (aborting(bank) without bank info");
- }
- const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
- logger.info(`aborting withdrawal at ${abortUrl}`);
- const abortResp = await ws.http.fetch(abortUrl, {
- method: "POST",
- body: {},
- });
- logger.info(`abort response status: ${abortResp.status}`);
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.AbortedBank;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
-}
-
-/**
- * Store in the database that the KYC for a withdrawal is now
- * satisfied.
- */
-async function transitionKycSatisfied(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingKyc: {
- delete wg2.kycPending;
- delete wg2.kycUrl;
- wg2.status = WithdrawalGroupStatus.PendingReady;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processWithdrawalGroupPendingKyc(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const userType = "individual";
- const kycInfo = withdrawalGroup.kycPending;
- if (!kycInfo) {
- throw Error("no kyc info available in pending(kyc)");
- }
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "30000");
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);
- runLongpollAsync(ws, retryTag, async (cancellationToken) => {
- logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- logger.info(
- `kyc long-polling response status: HTTP ${kycStatusRes.status}`,
- );
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- await transitionKycSatisfied(ws, withdrawalGroup);
- return { ready: true };
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const kycUrl = kycStatus.kyc_url;
- if (typeof kycUrl === "string") {
- await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
- }
- return { ready: false };
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- return { ready: false };
- } else {
- throw Error(
- `unexpected response from kyc-check (${kycStatusRes.status})`,
- );
- }
- });
- return TaskRunResult.longpoll();
-}
-
-async function processWithdrawalGroupPendingReady(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- await ws.exchangeOps.fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
-
- if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
- logger.warn("Finishing empty withdrawal group (no denoms)");
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.Done;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- const wgContext: WithdrawalGroupContext = {
- numPlanchets: numTotalCoins,
- planchetsFinished: new Set<string>(),
- wgRecord: withdrawalGroup,
- };
-
- await ws.db
- .mktx((x) => [x.planchets])
- .runReadOnly(async (tx) => {
- const planchets =
- await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
- for (const p of planchets) {
- if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- wgContext.planchetsFinished.add(p.coinPub);
- }
- }
- });
-
- // We sequentially generate planchets, so that
- // large withdrawal groups don't make the wallet unresponsive.
- for (let i = 0; i < numTotalCoins; i++) {
- await processPlanchetGenerate(ws, withdrawalGroup, i);
- }
-
- const maxBatchSize = 100;
-
- for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
- const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
- batchSize: maxBatchSize,
- coinStartIndex: i,
- });
- let work: Promise<void>[] = [];
- work = [];
- for (let j = 0; j < resp.coinIdxs.length; j++) {
- if (!resp.batchResp.ev_sigs[j]) {
- //response may not be available when there is kyc needed
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(
- ws,
- wgContext,
- resp.coinIdxs[j],
- resp.batchResp.ev_sigs[j],
- ),
- );
- }
- await Promise.all(work);
- }
-
- let numFinished = 0;
- const errorsPerCoin: Record<number, TalerErrorDetail> = {};
- let numPlanchetErrors = 0;
- const maxReportedErrors = 5;
-
- const res = await ws.db
- .mktx((x) => [x.coins, x.coinAvailability, x.withdrawalGroups, x.planchets])
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- numPlanchetErrors++;
- if (numPlanchetErrors < maxReportedErrors) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- }
- });
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- wg.status = WithdrawalGroupStatus.Done;
- await makeCoinsVisible(ws, tx, transactionId);
- }
-
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
-
- return {
- kycInfo: wg.kycPending,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- });
-
- if (!res) {
- throw Error("withdrawal group does not exist anymore");
- }
-
- notifyTransition(ws, transactionId, res.transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- if (numPlanchetErrors > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- {
- errorsPerCoin,
- numErrors: numPlanchetErrors,
- },
- ),
- };
- }
-
- return TaskRunResult.finished();
-}
-
-export async function processWithdrawalGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<TaskRunResult> {
- logger.trace("processing withdrawal group", withdrawalGroupId);
- const withdrawalGroup = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadOnly(async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- });
-
- if (!withdrawalGroup) {
- throw Error(`withdrawal group ${withdrawalGroupId} not found`);
- }
-
- const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);
-
- // We're already running!
- if (ws.activeLongpoll[retryTag]) {
- logger.info("withdrawal group already in long-polling, returning!");
- return {
- type: TaskRunResultType.Longpoll,
- };
- }
-
- switch (withdrawalGroup.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- await processReserveBankStatus(ws, withdrawalGroupId);
- // FIXME: This will get called by the main task loop, why call it here?!
- return await processWithdrawalGroup(ws, withdrawalGroupId);
- case WithdrawalGroupStatus.PendingQueryingStatus: {
- runLongpollAsync(ws, retryTag, (ct) => {
- return queryReserve(ws, withdrawalGroupId, ct);
- });
- logger.trace(
- "returning early from withdrawal for long-polling in background",
- );
- return {
- type: TaskRunResultType.Longpoll,
- };
- }
- case WithdrawalGroupStatus.PendingWaitConfirmBank: {
- const res = await processReserveBankStatus(ws, withdrawalGroupId);
- switch (res.status) {
- case BankStatusResultCode.Aborted:
- case BankStatusResultCode.Done:
- return TaskRunResult.finished();
- case BankStatusResultCode.Waiting: {
- return TaskRunResult.pending();
- }
- }
- break;
- }
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted: {
- // FIXME
- return TaskRunResult.pending();
- }
- case WithdrawalGroupStatus.PendingAml:
- // FIXME: Handle this case, withdrawal doesn't support AML yet.
- return TaskRunResult.pending();
- case WithdrawalGroupStatus.PendingKyc:
- return processWithdrawalGroupPendingKyc(ws, withdrawalGroup);
- case WithdrawalGroupStatus.PendingReady:
- // Continue with the actual withdrawal!
- return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortingBank:
- return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- // Nothing to do.
- return TaskRunResult.finished();
- default:
- assertUnreachable(withdrawalGroup.status);
- }
-}
-
-const AGE_MASK_GROUPS = "8:10:12:14:16:18"
- .split(":")
- .map((n) => parseInt(n, 10));
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- instructedAmount: AmountJson,
- ageRestricted: number | undefined,
-): Promise<ExchangeWithdrawalDetails> {
- logger.trace("updating exchange");
- const exchange = await ws.exchangeOps.fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != instructedAmount.currency) {
- // Specifiying the amount in the conversion input currency is not yet supported.
- // We might add support for it later.
- throw new Error(
- `withdrawal only supported when specifying target currency ${exchange.currency}`,
- );
- }
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount,
- });
-
- logger.trace("updating withdrawal denoms");
- await updateWithdrawalDenoms(ws, exchangeBaseUrl);
-
- logger.trace("getting candidate denoms");
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
- logger.trace("selecting withdrawal denoms");
- const selectedDenoms = selectWithdrawalDenominations(
- instructedAmount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
-
- logger.trace("selection done");
-
- if (selectedDenoms.selectedDenoms.length === 0) {
- throw Error(
- `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
- instructedAmount,
- )}`,
- );
- }
-
- const exchangeWireAccounts: string[] = [];
-
- for (const account of exchange.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- let hasDenomWithAgeRestriction = false;
-
- logger.trace("computing earliest deposit expiration");
-
- let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
- for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
- const ds = selectedDenoms.selectedDenoms[i];
- // FIXME: Do in one transaction!
- const denom = await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
- });
- checkDbInvariant(!!denom);
- hasDenomWithAgeRestriction =
- hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
- const expireDeposit = denom.stampExpireDeposit;
- if (!earliestDepositExpiration) {
- earliestDepositExpiration = expireDeposit;
- continue;
- }
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromProtocolTimestamp(expireDeposit),
- AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
- ) < 0
- ) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- checkLogicInvariant(!!earliestDepositExpiration);
-
- const possibleDenoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
-
- let versionMatch;
- if (exchange.protocolVersionRange) {
- versionMatch = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchange.protocolVersionRange,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- logger.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
- if (exchange.tosAcceptedTimestamp) {
- if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
- tosAccepted = true;
- }
- }
-
- const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
-
- const ret: ExchangeWithdrawalDetails = {
- earliestDepositExpiration,
- exchangePaytoUris: paytoUris,
- exchangeWireAccounts,
- exchangeCreditAccountDetails: withdrawalAccountsList,
- exchangeVersion: exchange.protocolVersionRange || "unknown",
- numOfferedDenoms: possibleDenoms.length,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- termsOfServiceAccepted: tosAccepted,
- withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
- withdrawalAmountRaw: Amounts.stringify(instructedAmount),
- // TODO: remove hardcoding, this should be calculated from the denominations info
- // force enabled for testing
- ageRestrictionOptions: hasDenomWithAgeRestriction
- ? AGE_MASK_GROUPS
- : undefined,
- };
- return ret;
-}
-
-export interface GetWithdrawalDetailsForUriOpts {
- restrictAge?: number;
-}
-
-/**
- * Get more information about a taler://withdraw URI.
- *
- * As side effects, the bank (via the bank integration API) is queried
- * and the exchange suggested by the bank is permanently added
- * to the wallet's list of known exchanges.
- */
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- opts: GetWithdrawalDetailsForUriOpts = {},
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- // FIXME: right now the exchange gets permanently added,
- // we might want to only temporarily add it.
- try {
- await ws.exchangeOps.fetchFreshExchange(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- // Extract information about possible exchanges for the withdrawal
- // operation from the database.
-
- const exchanges: ExchangeListItem[] = [];
-
- await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.operationRetries,
- ])
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const exchangeDetails = await ws.exchangeOps.getExchangeDetails(
- tx,
- r.baseUrl,
- );
- const retryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(r),
- );
- if (exchangeDetails) {
- exchanges.push(
- makeExchangeListItem(r, exchangeDetails, retryRecord?.lastError),
- );
- }
- }
- });
-
- return {
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges: exchanges,
- };
-}
-
-export function augmentPaytoUrisForWithdrawal(
- plainPaytoUris: string[],
- reservePub: string,
- instructedAmount: AmountLike,
-): string[] {
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(instructedAmount),
- message: `Taler Withdrawal ${reservePub}`,
- }),
- );
-}
-
-/**
- * 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 augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-}
-
-async function getWithdrawalGroupRecordTx(
- db: DbAccess<typeof WalletStoresV1>,
- req: {
- withdrawalGroupId: string;
- },
-): Promise<WithdrawalGroupRecord | undefined> {
- return await db
- .mktx((x) => [x.withdrawalGroups])
- .runReadOnly(async (tx) => {
- return tx.withdrawalGroups.get(req.withdrawalGroupId);
- });
-}
-
-export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
- return { d_ms: 60000 };
-}
-
-export function getBankStatusUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-export function getBankAbortUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- const withdrawalGroup = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadOnly(async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return;
- }
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error();
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
- const reqBody = {
- reserve_pub: withdrawalGroup.reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- };
- logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
- const httpResp = await ws.http.fetch(bankStatusUrl, {
- method: "POST",
- body: reqBody,
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
- await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
- );
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-interface BankStatusResult {
- status: BankStatusResultCode;
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<BankStatusResult> {
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return {
- status: BankStatusResultCode.Done,
- };
- }
-
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("wrong withdrawal record type");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return {
- status: BankStatusResultCode.Done,
- };
- }
-
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
-
- const statusResp = await ws.http.fetch(bankStatusUrl, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.info("bank aborted the withdrawal");
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.FailedBankAborted;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return {
- status: BankStatusResultCode.Aborted,
- };
- }
-
- // 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.status === WithdrawalGroupStatus.PendingRegisteringBank) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- const transitionInfo = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadWrite(async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- // Re-check reserve status within transaction
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return undefined;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const oldTxState = computeWithdrawalTransactionStatus(r);
- if (status.transfer_done) {
- logger.info("withdrawal: transfer confirmed by bank.");
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.PendingQueryingStatus;
- } else {
- logger.trace("withdrawal: transfer not yet confirmed by bank");
- r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
- r.senderWire = status.sender_wire;
- }
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- if (status.transfer_done) {
- return {
- status: BankStatusResultCode.Done,
- };
- } else {
- return {
- status: BankStatusResultCode.Waiting,
- };
- }
-}
-
-export interface PrepareCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transactionId: string;
- creationInfo?: {
- amount: AmountJson;
- canonExchange: string;
- };
-}
-
-export async function internalPrepareCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<PrepareCreateWithdrawalGroupResult> {
- const reserveKeyPair =
- args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const secretSeed = encodeCrock(getRandomBytes(32));
- const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
- const amount = args.amount;
- const currency = Amounts.currencyOf(amount);
-
- let withdrawalGroupId;
-
- if (args.forcedWithdrawalGroupId) {
- withdrawalGroupId = args.forcedWithdrawalGroupId;
- const wgId = withdrawalGroupId;
- const existingWg = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadOnly(async (tx) => {
- return tx.withdrawalGroups.get(wgId);
- });
-
- if (existingWg) {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWg.withdrawalGroupId,
- });
- return { withdrawalGroup: existingWg, transactionId };
- }
- } else {
- withdrawalGroupId = encodeCrock(getRandomBytes(32));
- }
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- canonExchange,
- currency,
- );
-
- let initialDenomSel: DenomSelectionState;
- const denomSelUid = encodeCrock(getRandomBytes(16));
- if (args.forcedDenomSel) {
- logger.warn("using forced denom selection");
- initialDenomSel = selectForcedWithdrawalDenominations(
- amount,
- denoms,
- args.forcedDenomSel,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- initialDenomSel = selectWithdrawalDenominations(
- amount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
- }
-
- const withdrawalGroup: WithdrawalGroupRecord = {
- denomSelUid,
- denomsSel: initialDenomSel,
- exchangeBaseUrl: canonExchange,
- instructedAmount: Amounts.stringify(amount),
- timestampStart: timestampPreciseToDb(now),
- rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
- effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
- secretSeed,
- reservePriv: reserveKeyPair.priv,
- reservePub: reserveKeyPair.pub,
- status: args.reserveStatus,
- withdrawalGroupId,
- restrictAge: args.restrictAge,
- senderWire: undefined,
- timestampFinish: undefined,
- wgInfo: args.wgInfo,
- };
-
- const exchangeInfo = await fetchFreshExchange(ws, canonExchange);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
-
- return {
- withdrawalGroup,
- transactionId,
- creationInfo: {
- canonExchange,
- amount,
- },
- };
-}
-
-export interface PerformCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transitionInfo: TransitionInfo | undefined;
-}
-
-export async function internalPerformCreateWithdrawalGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- reserves: typeof WalletStoresV1.reserves;
- exchanges: typeof WalletStoresV1.exchanges;
- }>,
- prep: PrepareCreateWithdrawalGroupResult,
-): Promise<PerformCreateWithdrawalGroupResult> {
- const { withdrawalGroup } = prep;
- if (!prep.creationInfo) {
- return { withdrawalGroup, transitionInfo: undefined };
- }
- await tx.withdrawalGroups.add(withdrawalGroup);
- await tx.reserves.put({
- reservePub: withdrawalGroup.reservePub,
- reservePriv: withdrawalGroup.reservePriv,
- });
-
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (exchange) {
- exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
- exchange.entryStatus = ExchangeEntryDbRecordStatus.Used;
- await tx.exchanges.put(exchange);
- }
-
- const oldTxState = {
- major: TransactionMajorState.None,
- minor: undefined,
- };
- const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
- const transitionInfo = {
- oldTxState,
- newTxState,
- };
-
- return { withdrawalGroup, transitionInfo };
-}
-
-/**
- * Create a withdrawal group.
- *
- * If a forcedWithdrawalGroupId is given and a
- * withdrawal group with this ID already exists,
- * the existing one is returned. No conflict checking
- * of the other arguments is done in that case.
- */
-export async function internalCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<WithdrawalGroupRecord> {
- const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
- });
- const res = await ws.db
- .mktx((x) => [
- x.withdrawalGroups,
- x.reserves,
- x.exchanges,
- x.exchangeDetails,
- ])
- .runReadWrite(async (tx) => {
- return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
- });
- notifyTransition(ws, transactionId, res.transitionInfo);
- return res.withdrawalGroup;
-}
-
-export async function acceptWithdrawalFromUri(
- ws: InternalWalletState,
- req: {
- talerWithdrawUri: string;
- selectedExchange: string;
- forcedDenomSel?: ForcedDenomSel;
- restrictAge?: number;
- },
-): Promise<AcceptWithdrawalResponse> {
- const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
- logger.info(
- `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
- );
- const existingWithdrawalGroup = await ws.db
- .mktx((x) => [x.withdrawalGroups])
- .runReadOnly(async (tx) => {
- return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- req.talerWithdrawUri,
- );
- });
-
- if (existingWithdrawalGroup) {
- let url: string | undefined;
- if (
- existingWithdrawalGroup.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
- }
- return {
- reservePub: existingWithdrawalGroup.reservePub,
- confirmTransferUrl: url,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
- }),
- };
- }
-
- await fetchFreshExchange(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(
- ws.http,
- req.talerWithdrawUri,
- );
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
-
- const exchange = await ws.exchangeOps.fetchFreshExchange(
- ws,
- selectedExchange,
- );
-
- const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: withdrawInfo.amount,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: withdrawInfo.amount,
- exchangeBaseUrl: req.selectedExchange,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankIntegrated,
- exchangeCreditAccounts: withdrawalAccountList,
- bankInfo: {
- exchangePaytoUri,
- talerWithdrawUri: req.talerWithdrawUri,
- confirmUrl: withdrawInfo.confirmTransferUrl,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- },
- },
- restrictAge: req.restrictAge,
- forcedDenomSel: req.forcedDenomSel,
- reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- 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?.status === WithdrawalGroupStatus.FailedBankAborted
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- {},
- );
- }
-
- ws.workAvailable.trigger();
-
- return {
- reservePub: withdrawalGroup.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- transactionId,
- };
-}
-
-async function fetchAccount(
- ws: InternalWalletState,
- instructedAmount: AmountJson,
- acct: ExchangeWireAccount,
- reservePub?: string,
-): Promise<WithdrawalExchangeAccountDetails> {
- let paytoUri: string;
- let transferAmount: AmountString | undefined = undefined;
- let currencySpecification: CurrencySpecification | undefined = undefined;
- if (acct.conversion_url != null) {
- const reqUrl = new URL("cashin-rate", acct.conversion_url);
- reqUrl.searchParams.set(
- "amount_credit",
- Amounts.stringify(instructedAmount),
- );
- const httpResp = await ws.http.fetch(reqUrl.href);
- const respOrErr = await readSuccessResponseJsonOrErrorCode(
- httpResp,
- codecForCashinConversionResponse(),
- );
- if (respOrErr.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: respOrErr.talerErrorResponse,
- };
- }
- const resp = respOrErr.response;
- paytoUri = acct.payto_uri;
- transferAmount = resp.amount_debit;
- const configUrl = new URL("config", acct.conversion_url);
- const configResp = await ws.http.fetch(configUrl.href);
- const configRespOrError = await readSuccessResponseJsonOrErrorCode(
- configResp,
- codecForConversionBankConfig(),
- );
- if (configRespOrError.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: configRespOrError.talerErrorResponse,
- };
- }
- const configParsed = configRespOrError.response;
- currencySpecification = configParsed.fiat_currency_specification;
- } else {
- paytoUri = acct.payto_uri;
- transferAmount = Amounts.stringify(instructedAmount);
- }
- paytoUri = addPaytoQueryParams(paytoUri, {
- amount: Amounts.stringify(transferAmount),
- });
- if (reservePub != null) {
- paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler Withdrawal ${reservePub}`,
- });
- }
- const acctInfo: WithdrawalExchangeAccountDetails = {
- status: "ok",
- paytoUri,
- transferAmount,
- currencySpecification,
- creditRestrictions: acct.credit_restrictions,
- };
- if (transferAmount != null) {
- acctInfo.transferAmount = transferAmount;
- }
- return acctInfo;
-}
-
-/**
- * Gather information about bank accounts that can be used for
- * withdrawals. This includes accounts that are in a different
- * currency and require conversion.
- */
-async function fetchWithdrawalAccountInfo(
- ws: InternalWalletState,
- req: {
- exchange: ReadyExchangeSummary;
- instructedAmount: AmountJson;
- reservePub?: string;
- },
-): Promise<WithdrawalExchangeAccountDetails[]> {
- const { exchange, instructedAmount } = req;
- const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
- for (let acct of exchange.wireInfo.accounts) {
- const acctInfo = await fetchAccount(
- ws,
- req.instructedAmount,
- acct,
- req.reservePub,
- );
- withdrawalAccounts.push(acctInfo);
- }
- return withdrawalAccounts;
-}
-
-/**
- * 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 { exchangeBaseUrl } = req;
- const amount = Amounts.parseOrThrow(req.amount);
- const exchange = await ws.exchangeOps.fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != amount.currency) {
- throw Error(
- "manual withdrawal with conversion from foreign currency is not yet supported",
- );
- }
- const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
- {},
- );
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: amount,
- reservePub: reserveKeyPair.pub,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.jsonifyAmount(req.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankManual,
- exchangeCreditAccounts: withdrawalAccountsList,
- },
- exchangeBaseUrl: req.exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- restrictAge: req.restrictAge,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const exchangePaytoUris = await ws.db
- .mktx((x) => [x.withdrawalGroups, x.exchanges, x.exchangeDetails])
- .runReadOnly(async (tx) => {
- return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
- });
-
- ws.workAvailable.trigger();
-
- return {
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris: exchangePaytoUris,
- withdrawalAccountsList: withdrawalAccountsList,
- transactionId,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 5d58f4c2f..090a11cf0 100644
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -30,12 +30,21 @@ import {
AbsoluteTime,
AmountJson,
Amounts,
+ AmountString,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ CheckPaymentResponse,
+ CheckPayTemplateReponse,
+ CheckPayTemplateRequest,
codecForAbortResponse,
codecForMerchantContractTerms,
- codecForMerchantOrderRefundPickupResponse,
codecForMerchantOrderStatusPaid,
codecForMerchantPayResponse,
+ codecForPostOrderResponse,
codecForProposal,
+ codecForWalletRefundResponse,
+ codecForWalletTemplateDetails,
CoinDepositPermission,
CoinRefreshRequest,
ConfirmPayResult,
@@ -53,14 +62,17 @@ import {
MerchantCoinRefundStatus,
MerchantContractTerms,
MerchantPayResponse,
+ MerchantUsingTemplateDetails,
NotificationType,
+ parsePayTemplateUri,
parsePayUri,
parseTalerUri,
- PayCoinSelection,
PreparePayResult,
PreparePayResultType,
+ PreparePayTemplateRequest,
randomBytes,
RefreshReason,
+ SelectedProspectiveCoin,
SharePaymentResult,
StartRefundQueryForUriResponse,
stringifyPayUri,
@@ -68,10 +80,13 @@ import {
TalerError,
TalerErrorCode,
TalerErrorDetail,
+ TalerMerchantApi,
+ TalerMerchantInstanceHttpClient,
TalerPreciseTimestamp,
TalerProtocolViolationError,
TalerUriAction,
TransactionAction,
+ TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
@@ -87,45 +102,38 @@ import {
readUnexpectedResponseDetails,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
+import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ spendCoins,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResultType,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
import {
- BackupProviderStateTag,
CoinRecord,
+ DbCoinSelection,
DenominationRecord,
PurchaseRecord,
PurchaseStatus,
- RefundReason,
- WalletStoresV1,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- PendingTaskType,
RefundGroupRecord,
RefundGroupStatus,
RefundItemRecord,
RefundItemStatus,
+ RefundReason,
timestampPreciseToDb,
timestampProtocolFromDb,
timestampProtocolToDb,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import {
- constructTaskIdentifier,
- DbRetryInfo,
- runLongpollAsync,
- runTaskWithErrorReporting,
- spendCoins,
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
-} from "./common.js";
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+} from "./db.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
import {
calculateRefreshOutput,
createRefreshGroup,
@@ -134,14 +142,323 @@ import {
import {
constructTransactionIdentifier,
notifyTransition,
- stopLongpolling,
+ parseTransactionIdentifier,
} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
/**
* Logger.
*/
const logger = new Logger("pay-merchant.ts");
+export class PayMerchantTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public proposalId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+ }
+
+ /**
+ * Transition a payment transition.
+ */
+ async transition(
+ f: (rec: PurchaseRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ /**
+ * Transition a payment transition.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PurchaseRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["purchases", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const ws = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", ...extraStores] },
+ async (tx) => {
+ const purchaseRec = await tx.purchases.get(this.proposalId);
+ if (!purchaseRec) {
+ throw Error("purchase not found anymore");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchaseRec);
+ const res = await f(purchaseRec, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.purchases.put(purchaseRec);
+ const newTxState = computePayMerchantTransactionState(purchaseRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, proposalId } = this;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", "tombstones"] },
+ async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionSuspend[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ const oldStatus = purchase.purchaseStatus;
+ switch (oldStatus) {
+ case PurchaseStatus.Done:
+ return;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying: {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(
+ purchase.payInfo.totalPayCost,
+ );
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
+ await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
+ this.transactionId,
+ );
+ }
+ break;
+ }
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.PendingAcceptRefund:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.PendingQueryingRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ if (!purchase.timestampFirstSuccessfulPay) {
+ throw Error("invalid state");
+ }
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ break;
+ case PurchaseStatus.DialogProposed:
+ purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ break;
+ default:
+ return;
+ }
+ await tx.purchases.put(purchase);
+ await tx.operationRetries.delete(this.taskId);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionResume[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newState: PurchaseStatus | undefined = undefined;
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.AbortingWithRefund:
+ newState = PurchaseStatus.FailedAbort;
+ break;
+ }
+ if (newState) {
+ purchase.purchaseStatus = newState;
+ await tx.purchases.put(purchase);
+ }
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+}
+
+export class RefundTransactionContext implements TransactionContext {
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr | undefined = undefined;
+ constructor(
+ public wex: WalletExecutionContext,
+ public refundGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, refundGroupId, transactionId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refundGroups", "tombstones"] },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ },
+ );
+ }
+
+ suspendTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ resumeTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ failTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+}
+
/**
* Compute the total cost of a payment to the customer.
*
@@ -150,54 +467,42 @@ const logger = new Logger("pay-merchant.ts");
* of coins that are too small to spend.
*/
export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
+ wex: WalletExecutionContext,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs.paymentAmount);
- return ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
+ for (let i = 0; i < pcs.length; i++) {
const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
- ws.config.testing.denomselAllowLate,
);
- costs.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
costs.push(refreshCost);
}
- const zero = Amounts.zeroOfAmount(pcs.paymentAmount);
+ const zero = Amounts.zeroOfCurrency(currency);
return Amounts.sum([zero, ...costs]).amount;
- });
+ },
+ );
}
async function failProposalPermanently(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
@@ -205,9 +510,9 @@ async function failProposalPermanently(
tag: TransactionType.Payment,
proposalId,
});
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
@@ -218,24 +523,15 @@ async function failProposalPermanently(
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration {
- return Duration.clamp({
- lower: Duration.fromSpec({ seconds: 1 }),
- upper: Duration.fromSpec({ seconds: 60 }),
- value: retryInfo
- ? DbRetryInfo.getDuration(retryInfo)
- : Duration.fromSpec({}),
- });
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
}
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
return Duration.multiply(
{ d_ms: 15000 },
- 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
);
}
@@ -243,11 +539,9 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
* Return the proposal download data for a purchase, throw if not available.
*/
export async function expectProposalDownload(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
p: PurchaseRecord,
- parentTx?: GetReadOnlyAccess<{
- contractTerms: typeof WalletStoresV1.contractTerms;
- }>,
+ parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
): Promise<{
contractData: WalletContractData;
contractTermsRaw: any;
@@ -279,9 +573,10 @@ export async function expectProposalDownload(
if (parentTx) {
return getFromTransaction(parentTx);
}
- return await ws.db
- .mktx((x) => [x.contractTerms])
- .runReadOnly(getFromTransaction);
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ getFromTransaction,
+ );
}
export function extractContractData(
@@ -290,12 +585,6 @@ export function extractContractData(
merchantSig: string,
): WalletContractData {
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.zeroOfCurrency(amount.currency);
- }
return {
amount: Amounts.stringify(amount),
contractTermsHash: contractTermsHash,
@@ -306,10 +595,8 @@ export function extractContractData(
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
- maxWireFee: Amounts.stringify(maxWireFee),
payDeadline: parsedContractTerms.pay_deadline,
refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
@@ -325,27 +612,32 @@ export function extractContractData(
}
async function processDownloadProposal(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const proposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return await tx.purchases.get(proposalId);
- });
+ },
+ );
if (!proposal) {
return TaskRunResult.finished();
}
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
+ logger.error(
+ `unexpected state ${proposal.purchaseStatus}/${
+ PurchaseStatus[proposal.purchaseStatus]
+ } for ${ctx.transactionId} in processDownloadProposal`,
+ );
return TaskRunResult.finished();
}
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
+ const transactionId = ctx.transactionId;
const orderClaimUrl = new URL(
`orders/${proposal.orderId}/claim`,
@@ -363,17 +655,10 @@ async function processDownloadProposal(
requestBody.token = proposal.claimToken;
}
- const opId = TaskIdentifiers.forPay(proposal);
- const retryRecord = await ws.db
- .mktx((x) => [x.operationRetries])
- .runReadOnly(async (tx) => {
- return tx.operationRetries.get(opId);
- });
-
- const httpResponse = await ws.http.fetch(orderClaimUrl, {
+ const httpResponse = await wex.http.fetch(orderClaimUrl, {
method: "POST",
body: requestBody,
- timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
+ cancellationToken: wex.cancellationToken,
});
const r = await readSuccessResponseJsonOrErrorCode(
httpResponse,
@@ -418,7 +703,7 @@ async function processDownloadProposal(
{},
"validation for well-formedness failed",
);
- await failProposalPermanently(ws, proposalId, err);
+ await failProposalPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -444,7 +729,7 @@ async function processDownloadProposal(
{},
`schema validation failed: ${e}`,
);
- await failProposalPermanently(ws, proposalId, err);
+ await failProposalPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -452,7 +737,7 @@ async function processDownloadProposal(
);
}
- const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
+ const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
contractTermsHash,
merchantPub: parsedContractTerms.merchant_pub,
sig: proposalResp.sig,
@@ -467,7 +752,7 @@ async function processDownloadProposal(
},
"merchant's signature on contract terms is invalid",
);
- await failProposalPermanently(ws, proposalId, err);
+ await failProposalPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -489,7 +774,7 @@ async function processDownloadProposal(
},
"merchant base URL mismatch",
);
- await failProposalPermanently(ws, proposalId, err);
+ await failProposalPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -505,9 +790,9 @@ async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`);
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases, x.contractTerms])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases", "contractTerms"] },
+ async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
@@ -537,7 +822,12 @@ async function processDownloadProposal(
}
// FIXME: Adjust this to account for refunds, don't count as repurchase
// if original order is refunded.
- if (otherPurchase && otherPurchase.refundAmountAwaiting === undefined) {
+ if (
+ otherPurchase &&
+ (otherPurchase.purchaseStatus == PurchaseStatus.Done ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay)
+ ) {
logger.warn("repurchase detected");
p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
p.repurchaseProposalId = otherPurchase.proposalId;
@@ -553,11 +843,12 @@ async function processDownloadProposal(
oldTxState,
newTxState,
};
- });
+ },
+ );
- notifyTransition(ws, transactionId, transitionInfo);
+ notifyTransition(wex, transactionId, transitionInfo);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
}
/**
@@ -565,22 +856,23 @@ async function processDownloadProposal(
* record for the provided arguments already exists,
* return the old proposal ID.
*/
-async function createPurchase(
- ws: InternalWalletState,
+async function createOrReusePurchase(
+ wex: WalletExecutionContext,
merchantBaseUrl: string,
orderId: string,
sessionId: string | undefined,
claimToken: string | undefined,
noncePriv: string | undefined,
): Promise<string> {
- const oldProposals = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const oldProposals = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.getAll([
merchantBaseUrl,
orderId,
]);
- });
+ },
+ );
const oldProposal = oldProposals.find((p) => {
return (
@@ -589,43 +881,51 @@ async function createPurchase(
p.claimToken === claimToken
);
});
- /* If we have already claimed this proposal with the same sessionId
- * nonce and claim token, reuse it. */
+ // If we have already claimed this proposal with the same sessionId
+ // nonce and claim token, reuse it. */
if (
oldProposal &&
oldProposal.downloadSessionId === sessionId &&
(!noncePriv || oldProposal.noncePriv === noncePriv) &&
oldProposal.claimToken === claimToken
) {
- // FIXME: This lacks proper error handling
- await processDownloadProposal(ws, oldProposal.proposalId);
-
+ logger.info(
+ `Found old proposal (status=${
+ PurchaseStatus[oldProposal.purchaseStatus]
+ }) for order ${orderId} at ${merchantBaseUrl}`,
+ );
if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
- const download = await expectProposalDownload(ws, oldProposal);
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
+ const download = await expectProposalDownload(wex, oldProposal);
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+ logger.info(`old proposal paid: ${paid}`);
if (paid) {
- //if this transaction was shared and the order is paid then it
- //means that another wallet already paid the proposal
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(oldProposal.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
+ },
+ );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId: oldProposal.proposalId,
});
- notifyTransition(ws, transactionId, transitionInfo);
+ notifyTransition(wex, transactionId, transitionInfo);
}
}
return oldProposal.proposalId;
@@ -637,10 +937,10 @@ async function createPurchase(
shared = true;
noncePair = {
priv: noncePriv,
- pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
};
} else {
- noncePair = await ws.cryptoApi.createEddsaKeypair({});
+ noncePair = await wex.cryptoApi.createEddsaKeypair({});
}
const { priv, pub } = noncePair;
@@ -671,9 +971,9 @@ async function createPurchase(
shared: shared,
};
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
await tx.purchases.put(proposalRecord);
const oldTxState: TransactionState = {
major: TransactionMajorState.None,
@@ -683,20 +983,19 @@ async function createPurchase(
oldTxState,
newTxState,
};
- });
+ },
+ );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
- notifyTransition(ws, transactionId, transitionInfo);
-
- await processDownloadProposal(ws, proposalId);
+ notifyTransition(wex, transactionId, transitionInfo);
return proposalId;
}
async function storeFirstPaySuccess(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
sessionId: string | undefined,
payResponse: MerchantPayResponse,
@@ -706,9 +1005,9 @@ async function storeFirstPaySuccess(
proposalId,
});
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases, x.contractTerms])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "purchases"] },
+ async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -756,12 +1055,13 @@ async function storeFirstPaySuccess(
oldTxState,
newTxState,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
}
async function storePayReplaySuccess(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
sessionId: string | undefined,
): Promise<void> {
@@ -769,9 +1069,9 @@ async function storePayReplaySuccess(
tag: TransactionType.Payment,
proposalId,
});
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -793,8 +1093,9 @@ async function storePayReplaySuccess(
await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
}
/**
@@ -806,17 +1107,18 @@ async function storePayReplaySuccess(
* (3) re-do coin selection with the bad coin removed
*/
async function handleInsufficientFunds(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
logger.trace("handling insufficient funds, trying to re-select coins");
- const proposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!proposal) {
return;
}
@@ -842,7 +1144,7 @@ async function handleInsufficientFunds(
throw new TalerProtocolViolationError();
}
- const { contractData } = await expectProposalDownload(ws, proposal);
+ const { contractData } = await expectProposalDownload(wex, proposal);
const prevPayCoins: PreviousPayCoins = [];
@@ -852,64 +1154,62 @@ async function handleInsufficientFunds(
}
const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
- await ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
- if (coinPub === brokenCoinPub) {
- continue;
- }
const contrib = payCoinSelection.coinContributions[i];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- continue;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- continue;
- }
prevPayCoins.push({
coinPub,
contribution: Amounts.parseOrThrow(contrib),
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
});
}
- });
+ },
+ );
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins,
requiredMinimumAge: contractData.minimumAge,
});
- if (res.type !== "success") {
- logger.trace("insufficient funds for coin re-selection");
- return;
+ switch (res.type) {
+ case "failure":
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ case "prospective":
+ return;
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
}
logger.trace("re-selected coins");
- await ws.db
- .mktx((x) => [
- x.purchases,
- x.coins,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- ])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
@@ -918,10 +1218,14 @@ async function handleInsufficientFunds(
if (!payInfo) {
return;
}
- payInfo.payCoinSelection = res.coinSel;
+ // Convert to DB format
+ payInfo.payCoinSelection = {
+ coinContributions: res.coinSel.coins.map((x) => x.contribution),
+ coinPubs: res.coinSel.coins.map((x) => x.coinPub),
+ };
payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
await tx.purchases.put(p);
- await spendCoins(ws, tx, {
+ await spendCoins(wex, tx, {
// allocationId: `txn:proposal:${p.proposalId}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.Payment,
@@ -933,9 +1237,10 @@ async function handleInsufficientFunds(
),
refreshReason: RefreshReason.PayMerchant,
});
- });
+ },
+ );
- ws.notify({
+ wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
@@ -944,41 +1249,20 @@ async function handleInsufficientFunds(
});
}
-async function unblockBackup(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => [x.backupProviders])
- .runReadWrite(async (tx) => {
- await tx.backupProviders.indexes.byPaymentProposalId
- .iter(proposalId)
- .forEachAsync(async (bp) => {
- bp.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- };
- tx.backupProviders.put(bp);
- });
- });
-}
-
-// FIXME: Should probably not be exported in its current state
// FIXME: Should take a transaction ID instead of a proposal ID
// FIXME: Does way more than checking the payment
// FIXME: Should return immediately.
-export async function checkPaymentByProposalId(
- ws: InternalWalletState,
+async function checkPaymentByProposalId(
+ wex: WalletExecutionContext,
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
- let proposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ let proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
@@ -986,17 +1270,18 @@ export async function checkPaymentByProposalId(
const existingProposalId = proposal.repurchaseProposalId;
if (existingProposalId) {
logger.trace("using existing purchase for same product");
- const oldProposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const oldProposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(existingProposalId);
- });
+ },
+ );
if (oldProposal) {
proposal = oldProposal;
}
}
}
- const d = await expectProposalDownload(ws, proposal);
+ const d = await expectProposalDownload(wex, proposal);
const contractData = d.contractData;
const merchantSig = d.contractData.merchantSig;
if (!merchantSig) {
@@ -1005,10 +1290,11 @@ export async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const transactionId = ctx.transactionId;
const talerUri = stringifyTalerUri({
type: TalerUriAction.Pay,
@@ -1019,47 +1305,63 @@ export async function checkPaymentByProposalId(
});
// First check if we already paid for it.
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (
!purchase ||
purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
purchase.purchaseStatus === PurchaseStatus.DialogShared
) {
+ const instructedAmount = Amounts.parseOrThrow(contractData.amount);
// If not already paid, check if we could pay for it.
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ contractTermsAmount: instructedAmount,
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
- wireMethod: contractData.wireMethod,
+ restrictWireMethod: contractData.wireMethod,
});
- if (res.type !== "success") {
- logger.info("not allowing payment, insufficient coins");
- logger.info(
- `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
- );
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
- talerUri,
- balanceDetails: res.insufficientBalanceDetails,
- };
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (res.type) {
+ case "failure": {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(
+ res.insufficientBalanceDetails,
+ )}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+ case "prospective":
+ coins = res.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = res.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(res);
}
- const totalCost = await getTotalPaymentCost(ws, res.coinSel);
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@@ -1069,7 +1371,7 @@ export async function checkPaymentByProposalId(
transactionId,
proposalId: proposal.proposalId,
amountEffective: Amounts.stringify(totalCost),
- amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
+ amountRaw: Amounts.stringify(instructedAmount),
contractTermsHash: d.contractData.contractTermsHash,
talerUri,
};
@@ -1083,9 +1385,9 @@ export async function checkPaymentByProposalId(
"automatically re-submitting payment with different session ID",
);
logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
@@ -1096,15 +1398,16 @@ export async function checkPaymentByProposalId(
await tx.purchases.put(p);
const newTxState = computePayMerchantTransactionState(p);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: What about error handling?! This doesn't properly store errors in the DB.
- const r = await processPurchasePay(ws, proposalId, { forceNow: true });
- if (r.type !== TaskRunResultType.Finished) {
- // FIXME: This does not surface the original error
- throw Error("submitting pay failed");
- }
- const download = await expectProposalDownload(ws, purchase);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Consider changing the API here so that we don't have to
+ // wait inline for the repurchase.
+
+ await waitPaymentResult(wex, proposalId, sessionId);
+ const download = await expectProposalDownload(wex, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
@@ -1119,12 +1422,12 @@ export async function checkPaymentByProposalId(
talerUri,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
contractTermsHash: download.contractData.contractTermsHash,
- paid: false,
+ paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: purchase.payInfo
? Amounts.stringify(purchase.payInfo.totalPayCost)
@@ -1138,7 +1441,7 @@ export async function checkPaymentByProposalId(
purchase.purchaseStatus === PurchaseStatus.Done ||
purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
@@ -1157,20 +1460,21 @@ export async function checkPaymentByProposalId(
}
export async function getContractTermsDetails(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
): Promise<WalletContractData> {
- const proposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
}
- const d = await expectProposalDownload(ws, proposal);
+ const d = await expectProposalDownload(wex, proposal);
return d.contractData;
}
@@ -1182,7 +1486,7 @@ export async function getContractTermsDetails(
* yet send to the merchant.
*/
export async function preparePayForUri(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
talerPayUri: string,
): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
@@ -1197,8 +1501,8 @@ export async function preparePayForUri(
);
}
- const proposalId = await createPurchase(
- ws,
+ const proposalId = await createOrReusePurchase(
+ wex,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
@@ -1206,7 +1510,187 @@ export async function preparePayForUri(
uriResult.noncePriv,
);
- return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
+ await waitProposalDownloaded(wex, proposalId);
+
+ return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Wait until a proposal is at least downloaded.
+ */
+async function waitProposalDownloaded(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ // FIXME: This doesn't support cancellation yet
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const payNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ payNotifFlag.raise();
+ }
+ });
+
+ try {
+ await internalWaitProposalDownloaded(ctx, payNotifFlag);
+ logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function internalWaitProposalDownloaded(
+ ctx: PayMerchantTransactionContext,
+ payNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
+ }
+ if (purchase.download) {
+ return;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ await payNotifFlag.wait();
+ payNotifFlag.reset();
+ }
+}
+
+async function downloadTemplate(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ templateId: string,
+): Promise<TalerMerchantApi.WalletTemplateDetails> {
+ const reqUrl = new URL(`templates/${templateId}`, merchantBaseUrl);
+ const httpReq = await wex.http.fetch(reqUrl.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForWalletTemplateDetails(),
+ );
+ return resp;
+}
+
+export async function checkPayForTemplate(
+ wex: WalletExecutionContext,
+ req: CheckPayTemplateRequest,
+): Promise<CheckPayTemplateReponse> {
+ const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
+ if (!parsedUri) {
+ throw Error("invalid taler-template URI");
+ }
+ const templateDetails = await downloadTemplate(
+ wex,
+ parsedUri.merchantBaseUrl,
+ parsedUri.templateId,
+ );
+
+ const merchantApi = new TalerMerchantInstanceHttpClient(
+ parsedUri.merchantBaseUrl,
+ wex.http,
+ );
+
+ const cfg = await merchantApi.getConfig();
+ if (cfg.type === "fail") {
+ throw TalerError.fromUncheckedDetail(cfg.detail);
+ }
+
+ return {
+ templateDetails,
+ supportedCurrencies: Object.keys(cfg.body.currencies),
+ };
+}
+
+export async function preparePayForTemplate(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayResult> {
+ const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
+ if (!parsedUri) {
+ throw Error("invalid taler-template URI");
+ }
+ logger.trace(`parsed URI: ${j2s(parsedUri)}`);
+ const templateDetails: MerchantUsingTemplateDetails = {};
+
+ const templateInfo = await downloadTemplate(
+ wex,
+ parsedUri.merchantBaseUrl,
+ parsedUri.templateId,
+ );
+
+ const templateParamsAmount = req.templateParams?.amount as
+ | AmountString
+ | undefined;
+ if (templateParamsAmount === null) {
+ const amountFromUri = templateInfo.editable_defaults?.amount;
+ if (amountFromUri != null) {
+ templateDetails.amount = amountFromUri as AmountString;
+ }
+ } else {
+ templateDetails.amount = templateParamsAmount;
+ }
+
+ const templateParamsSummary = req.templateParams?.summary;
+ if (templateParamsSummary === null) {
+ const summaryFromUri = templateInfo.editable_defaults?.summary;
+ if (summaryFromUri != null) {
+ templateDetails.summary = summaryFromUri;
+ }
+ } else {
+ templateDetails.summary = templateParamsSummary;
+ }
+
+ const reqUrl = new URL(
+ `templates/${parsedUri.templateId}`,
+ parsedUri.merchantBaseUrl,
+ );
+ const httpReq = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: templateDetails,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForPostOrderResponse(),
+ );
+
+ const payUri = stringifyPayUri({
+ merchantBaseUrl: parsedUri.merchantBaseUrl,
+ orderId: resp.order_id,
+ sessionId: "",
+ claimToken: resp.token,
+ });
+
+ return await preparePayForUri(wex, payUri);
}
/**
@@ -1215,8 +1699,8 @@ export async function preparePayForUri(
* Accesses the database and the crypto worker.
*/
export async function generateDepositPermissions(
- ws: InternalWalletState,
- payCoinSel: PayCoinSelection,
+ wex: WalletExecutionContext,
+ payCoinSel: DbCoinSelection,
contractData: WalletContractData,
): Promise<CoinDepositPermission[]> {
const depositPermissions: CoinDepositPermission[] = [];
@@ -1224,10 +1708,10 @@ export async function generateDepositPermissions(
coin: CoinRecord;
denom: DenominationRecord;
}> = [];
- await ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
if (!coin) {
throw Error("can't pay, allocated coin not found anymore");
@@ -1243,18 +1727,14 @@ export async function generateDepositPermissions(
}
coinWithDenom.push({ coin, denom });
}
- });
+ },
+ );
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
const { coin, denom } = coinWithDenom[i];
let wireInfoHash: string;
wireInfoHash = contractData.wireInfoHash;
- logger.trace(
- `signing deposit permission for coin with ageRestriction=${j2s(
- coin.ageCommitmentProof,
- )}`,
- );
- const dp = await ws.cryptoApi.signDepositPermission({
+ const dp = await wex.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: contractData.contractTermsHash,
@@ -1276,71 +1756,107 @@ export async function generateDepositPermissions(
return depositPermissions;
}
-/**
- * Run the operation handler for a payment
- * and return the result as a {@link ConfirmPayResult}.
- */
-async function runPayForConfirmPay(
- ws: InternalWalletState,
- proposalId: string,
+async function internalWaitPaymentResult(
+ ctx: PayMerchantTransactionContext,
+ purchaseNotifFlag: AsyncFlag,
+ waitSessionId?: string,
): Promise<ConfirmPayResult> {
- logger.trace("processing proposal for confirmPay");
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- const res = await runTaskWithErrorReporting(ws, taskId, async () => {
- return await processPurchasePay(ws, proposalId, { forceNow: true });
- });
- logger.trace(`processPurchasePay response type ${res.type}`);
- switch (res.type) {
- case TaskRunResultType.Finished: {
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- throw Error("purchase record not available anymore");
+ while (true) {
+ const txRes = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
+
+ const purchase = txRes.purchase;
+
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
+
+ const d = await expectProposalDownload(ctx.wex, purchase);
+
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
}
- const d = await expectProposalDownload(ws, purchase);
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- }),
- };
}
- case TaskRunResultType.Error: {
- // We hide transient errors from the caller.
- const opRetry = await ws.db
- .mktx((x) => [x.operationRetries])
- .runReadOnly(async (tx) => tx.operationRetries.get(taskId));
+
+ if (txRes.retryRecord) {
return {
type: ConfirmPayResultType.Pending,
- lastError: opRetry?.lastError,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- }),
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
};
}
- case TaskRunResultType.Pending:
- logger.trace("reporting pending as confirmPay response");
+
+ if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
return {
- type: ConfirmPayResultType.Pending,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- }),
- lastError: undefined,
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
};
- case TaskRunResultType.Longpoll:
- throw Error("unexpected processPurchasePay result (longpoll)");
- default:
- assertUnreachable(res);
+ }
+
+ await purchaseNotifFlag.wait();
+ purchaseNotifFlag.reset();
+ }
+}
+
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ // FIXME: We don't support cancelletion yet!
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const purchaseNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our purchase.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ purchaseNotifFlag.raise();
+ }
+ });
+
+ try {
+ logger.info(`waiting for first payment success on ${ctx.transactionId}`);
+ const res = await internalWaitPaymentResult(
+ ctx,
+ purchaseNotifFlag,
+ waitSessionId,
+ );
+ logger.info(
+ `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
+ );
+ return res;
+ } finally {
+ cancelNotif();
}
}
@@ -1348,37 +1864,38 @@ async function runPayForConfirmPay(
* Confirm payment for a proposal previously claimed by the wallet.
*/
export async function confirmPay(
- ws: InternalWalletState,
- proposalId: string,
+ wex: WalletExecutionContext,
+ transactionId: string,
sessionIdOverride?: string,
forcedCoinSel?: ForcedCoinSel,
): Promise<ConfirmPayResult> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ const proposalId = parsedTx.proposalId;
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
- const proposal = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
}
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- const d = await expectProposalDownload(ws, proposal);
+ const d = await expectProposalDownload(wex, proposal);
if (!d) {
throw Error("proposal is in invalid state");
}
- const existingPurchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const existingPurchase = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (
purchase &&
@@ -1393,42 +1910,62 @@ export async function confirmPay(
await tx.purchases.put(purchase);
}
return purchase;
- });
+ },
+ );
if (existingPurchase && existingPurchase.payInfo) {
logger.trace("confirmPay: submitting payment for existing purchase");
- return runPayForConfirmPay(ws, proposalId);
+ const ctx = new PayMerchantTransactionContext(
+ wex,
+ existingPurchase.proposalId,
+ );
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ return waitPaymentResult(wex, proposalId);
}
logger.trace("confirmPay: purchase record does not exist yet");
const contractData = d.contractData;
- const selectCoinsResult = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
forcedSelection: forcedCoinSel,
});
- logger.trace("coin selection result", selectCoinsResult);
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
- if (selectCoinsResult.type === "failure") {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
+ }
+ case "success":
+ coins = selectCoinsResult.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
}
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
let sessionId: string | undefined;
if (sessionIdOverride) {
@@ -1441,15 +1978,18 @@ export async function confirmPay(
`recording payment on ${proposal.orderId} with session ID ${sessionId}`,
);
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.purchases,
- x.coins,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
const p = await tx.purchases.get(proposal.proposalId);
if (!p) {
return;
@@ -1459,26 +1999,37 @@ export async function confirmPay(
case PurchaseStatus.DialogShared:
case PurchaseStatus.DialogProposed:
p.payInfo = {
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
totalPayCost: Amounts.stringify(payCostInfo),
};
+ if (selectCoinsResult.type === "success") {
+ p.payInfo.payCoinSelection = {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ }
p.lastSessionId = sessionId;
p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
p.purchaseStatus = PurchaseStatus.PendingPaying;
await tx.purchases.put(p);
- await spendCoins(ws, tx, {
- //`txn:proposal:${p.proposalId}`
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: coinSelection.coinPubs,
- contributions: coinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
break;
case PurchaseStatus.Done:
case PurchaseStatus.PendingPaying:
@@ -1487,26 +2038,34 @@ export async function confirmPay(
}
const newTxState = computePayMerchantTransactionState(p);
return { oldTxState, newTxState };
- });
+ },
+ );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
- return runPayForConfirmPay(ws, proposalId);
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ // In case we're sharing the payment and we're long-polling
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+
+ // Wait until we have completed the first attempt to pay.
+ return waitPaymentResult(wex, proposalId);
}
export async function processPurchase(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -1522,26 +2081,27 @@ export async function processPurchase(
switch (purchase.purchaseStatus) {
case PurchaseStatus.PendingDownloadingProposal:
- return processDownloadProposal(ws, proposalId);
+ return processDownloadProposal(wex, proposalId);
case PurchaseStatus.PendingPaying:
case PurchaseStatus.PendingPayingReplay:
- return processPurchasePay(ws, proposalId);
+ return processPurchasePay(wex, proposalId);
case PurchaseStatus.PendingQueryingRefund:
- return processPurchaseQueryRefund(ws, purchase);
+ return processPurchaseQueryRefund(wex, purchase);
case PurchaseStatus.PendingQueryingAutoRefund:
- return processPurchaseAutoRefund(ws, purchase);
+ return processPurchaseAutoRefund(wex, purchase);
case PurchaseStatus.AbortingWithRefund:
- return processPurchaseAbortingRefund(ws, purchase);
+ return processPurchaseAbortingRefund(wex, purchase);
case PurchaseStatus.PendingAcceptRefund:
- return processPurchaseAcceptRefund(ws, purchase);
+ return processPurchaseAcceptRefund(wex, purchase);
case PurchaseStatus.DialogShared:
- return processPurchaseDialogShared(ws, purchase);
+ return processPurchaseDialogShared(wex, purchase);
case PurchaseStatus.FailedClaim:
case PurchaseStatus.Done:
case PurchaseStatus.DoneRepurchaseDetected:
case PurchaseStatus.DialogProposed:
case PurchaseStatus.AbortedProposalRefused:
case PurchaseStatus.AbortedIncompletePayment:
+ case PurchaseStatus.AbortedOrderDeleted:
case PurchaseStatus.AbortedRefunded:
case PurchaseStatus.SuspendedAbortingWithRefund:
case PurchaseStatus.SuspendedDownloadingProposal:
@@ -1551,23 +2111,23 @@ export async function processPurchase(
case PurchaseStatus.SuspendedQueryingAutoRefund:
case PurchaseStatus.SuspendedQueryingRefund:
case PurchaseStatus.FailedAbort:
+ case PurchaseStatus.FailedPaidByOther:
return TaskRunResult.finished();
default:
assertUnreachable(purchase.purchaseStatus);
- // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`);
}
}
-export async function processPurchasePay(
- ws: InternalWalletState,
+async function processPurchasePay(
+ wex: WalletExecutionContext,
proposalId: string,
- options: unknown = {},
): Promise<TaskRunResult> {
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(proposalId);
- });
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -1589,38 +2149,45 @@ export async function processPurchasePay(
}
logger.trace(`processing purchase pay ${proposalId}`);
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
const sessionId = purchase.lastSessionId;
logger.trace(`paying with session ID ${sessionId}`);
const payInfo = purchase.payInfo;
checkDbInvariant(!!payInfo, "payInfo");
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
if (purchase.shared) {
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
if (paid) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
+ },
+ );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
- notifyTransition(ws, transactionId, transitionInfo);
+ notifyTransition(wex, transactionId, transitionInfo);
return {
type: TaskRunResultType.Error,
@@ -1632,6 +2199,110 @@ export async function processPurchasePay(
}
}
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${download.contractData.orderId}/pay`,
@@ -1641,7 +2312,7 @@ export async function processPurchasePay(
let depositPermissions: CoinDepositPermission[];
// FIXME: Cache!
depositPermissions = await generateDepositPermissions(
- ws,
+ wex,
payInfo.payCoinSelection,
download.contractData,
);
@@ -1651,16 +2322,16 @@ export async function processPurchasePay(
session_id: purchase.lastSessionId,
};
- logger.trace(
- "making pay request ... ",
- JSON.stringify(reqBody, undefined, 2),
- );
+ if (logger.shouldLogTrace()) {
+ logger.trace(`making pay request ... ${j2s(reqBody)}`);
+ }
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.fetch(payUrl, {
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payUrl, {
method: "POST",
body: reqBody,
timeout: getPayRequestTimeout(purchase),
+ cancellationToken: wex.cancellationToken,
}),
);
@@ -1686,20 +2357,24 @@ export async function processPurchasePay(
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
) {
// Do this in the background, as it might take some time
- handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
- console.log("handling insufficient funds failed");
- console.log(`${e.toString()}`);
+ // FIXME: Why? We're already in a (background) task!
+ handleInsufficientFunds(wex, proposalId, err).catch(async (e) => {
+ logger.error("handling insufficient funds failed");
+ logger.error(`${e.toString()}`);
});
// FIXME: Should we really consider this to be pending?
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
}
}
if (resp.status >= 400 && resp.status <= 499) {
logger.trace("got generic 4xx from merchant");
const err = await readTalerErrorResponse(resp);
+ if (logger.shouldLogTrace()) {
+ logger.trace(`error body: ${j2s(err)}`);
+ }
throwUnexpectedRequestError(resp, err);
}
@@ -1711,7 +2386,7 @@ export async function processPurchasePay(
logger.trace("got success from pay URL", merchantResp);
const merchantPub = download.contractData.merchantPub;
- const { valid } = await ws.cryptoApi.isValidPaymentSignature({
+ const { valid } = await wex.cryptoApi.isValidPaymentSignature({
contractHash: download.contractData.contractTermsHash,
merchantPub,
sig: merchantResp.sig,
@@ -1723,8 +2398,7 @@ export async function processPurchasePay(
throw Error("merchant payment signature invalid");
}
- await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp);
- await unblockBackup(ws, proposalId);
+ await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
} else {
const payAgainUrl = new URL(
`orders/${download.contractData.orderId}/paid`,
@@ -1736,8 +2410,12 @@ export async function processPurchasePay(
session_id: sessionId ?? "",
};
logger.trace(`/paid request body: ${j2s(reqBody)}`);
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.postJson(payAgainUrl, reqBody),
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payAgainUrl, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ }),
);
logger.trace(`/paid response status: ${resp.status}`);
if (
@@ -1750,24 +2428,23 @@ export async function processPurchasePay(
"/paid failed",
);
}
- await storePayReplaySuccess(ws, proposalId, sessionId);
- await unblockBackup(ws, proposalId);
+ await storePayReplaySuccess(wex, proposalId, sessionId);
}
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
}
export async function refuseProposal(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const proposal = await tx.purchases.get(proposalId);
if (!proposal) {
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
@@ -1784,118 +2461,10 @@ export async function refuseProposal(
const newTxState = computePayMerchantTransactionState(proposal);
await tx.purchases.put(proposal);
return { oldTxState, newTxState };
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPayMerchant(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.purchases,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- x.operationRetries,
- ])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- const oldStatus = purchase.purchaseStatus;
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- if (oldStatus === PurchaseStatus.PendingPaying) {
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- }
- await tx.purchases.put(purchase);
- if (oldStatus === PurchaseStatus.PendingPaying) {
- if (purchase.payInfo) {
- const coinSel = purchase.payInfo.payCoinSelection;
- const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < coinSel.coinPubs.length; i++) {
- refreshCoins.push({
- amount: coinSel.coinContributions[i],
- coinPub: coinSel.coinPubs[i],
- });
- }
- await createRefreshGroup(
- ws,
- tx,
- currency,
- refreshCoins,
- RefreshReason.AbortPay,
- );
- }
- }
- await tx.operationRetries.delete(opId);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.workAvailable.trigger();
-}
+ },
+ );
-export async function failPaymentTransaction(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.purchases,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- x.operationRetries,
- ])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newState: PurchaseStatus | undefined = undefined;
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.AbortingWithRefund:
- newState = PurchaseStatus.FailedAbort;
- break;
- }
- if (newState) {
- purchase.purchaseStatus = newState;
- await tx.purchases.put(purchase);
- }
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.workAvailable.trigger();
+ notifyTransition(wex, transactionId, transitionInfo);
}
const transitionSuspend: {
@@ -1942,73 +2511,6 @@ const transitionResume: {
},
};
-export async function suspendPayMerchant(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- stopLongpolling(ws, opId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionSuspend[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.workAvailable.trigger();
-}
-
-export async function resumePayMerchant(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- stopLongpolling(ws, opId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionResume[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
- ws.workAvailable.trigger();
-}
-
export function computePayMerchantTransactionState(
purchaseRecord: PurchaseRecord,
): TransactionState {
@@ -2102,6 +2604,7 @@ export function computePayMerchantTransactionState(
major: TransactionMajorState.Failed,
minor: TransactionMinorState.Refused,
};
+ case PurchaseStatus.AbortedOrderDeleted:
case PurchaseStatus.AbortedRefunded:
return {
major: TransactionMajorState.Aborted,
@@ -2129,6 +2632,13 @@ export function computePayMerchantTransactionState(
major: TransactionMajorState.Failed,
minor: TransactionMinorState.AbortingBank,
};
+ case PurchaseStatus.FailedPaidByOther:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.PaidByOther,
+ };
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
}
}
@@ -2182,7 +2692,7 @@ export function computePayMerchantTransactionActions(
return [];
// Final States
case PurchaseStatus.AbortedProposalRefused:
- return [TransactionAction.Delete];
+ case PurchaseStatus.AbortedOrderDeleted:
case PurchaseStatus.AbortedRefunded:
return [TransactionAction.Delete];
case PurchaseStatus.Done:
@@ -2195,17 +2705,21 @@ export function computePayMerchantTransactionActions(
return [TransactionAction.Delete];
case PurchaseStatus.FailedAbort:
return [TransactionAction.Delete];
+ case PurchaseStatus.FailedPaidByOther:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
}
}
export async function sharePayment(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
merchantBaseUrl: string,
orderId: string,
): Promise<SharePaymentResult> {
- const result = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const result = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.indexes.byUrlAndOrderId.get([
merchantBaseUrl,
orderId,
@@ -2218,25 +2732,42 @@ export async function sharePayment(
p.purchaseStatus !== PurchaseStatus.DialogProposed &&
p.purchaseStatus !== PurchaseStatus.DialogShared
) {
- //FIXME: purchase can be shared before being paid
+ // FIXME: purchase can be shared before being paid
return undefined;
}
+ const oldTxState = computePayMerchantTransactionState(p);
if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
p.purchaseStatus = PurchaseStatus.DialogShared;
p.shared = true;
- tx.purchases.put(p);
+ await tx.purchases.put(p);
}
+ const newTxState = computePayMerchantTransactionState(p);
+
return {
+ proposalId: p.proposalId,
nonce: p.noncePriv,
session: p.lastSessionId ?? p.downloadSessionId,
token: p.claimToken,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
};
- });
+ },
+ );
if (result === undefined) {
throw Error("This purchase can't be shared");
}
+
+ const ctx = new PayMerchantTransactionContext(wex, result.proposalId);
+
+ notifyTransition(wex, ctx.transactionId, result.transitionInfo);
+
+ // schedule a task to watch for the status
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
const privatePayUri = stringifyPayUri({
merchantBaseUrl,
orderId,
@@ -2244,12 +2775,14 @@ export async function sharePayment(
noncePriv: result.nonce,
claimToken: result.token,
});
+
return { privatePayUri };
}
async function checkIfOrderIsAlreadyPaid(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
contract: WalletContractData,
+ doLongPolling: boolean,
) {
const requestUrl = new URL(
`orders/${contract.orderId}`,
@@ -2257,9 +2790,14 @@ async function checkIfOrderIsAlreadyPaid(
);
requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
- requestUrl.searchParams.set("timeout_ms", "1000");
+ if (doLongPolling) {
+ requestUrl.searchParams.set("timeout_ms", "30000");
+ }
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
- const resp = await ws.http.fetch(requestUrl.href);
if (
resp.status === HttpStatusCode.Ok ||
resp.status === HttpStatusCode.Accepted ||
@@ -2269,185 +2807,178 @@ async function checkIfOrderIsAlreadyPaid(
} else if (resp.status === HttpStatusCode.PaymentRequired) {
return false;
}
- //forbidden, not found, not acceptable
+ // forbidden, not found, not acceptable
throw Error(`this order cant be paid: ${resp.status}`);
}
async function processPurchaseDialogShared(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
): Promise<TaskRunResult> {
const proposalId = purchase.proposalId;
logger.trace(`processing dialog-shared for proposal ${proposalId}`);
-
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
-
- // FIXME: Put this logic into runLongpollAsync?
- if (ws.activeLongpoll[taskId]) {
- return TaskRunResult.longpoll();
- }
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
return TaskRunResult.finished();
}
- runLongpollAsync(ws, taskId, async (ct) => {
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
- if (paid) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- return {
- ready: true,
- };
- }
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ true,
+ );
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
- return {
- ready: false,
- };
- });
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
- return TaskRunResult.longpoll();
+ return TaskRunResult.backoff();
}
async function processPurchaseAutoRefund(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
): Promise<TaskRunResult> {
const proposalId = purchase.proposalId;
logger.trace(`processing auto-refund for proposal ${proposalId}`);
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
-
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
- // FIXME: Put this logic into runLongpollAsync?
- if (ws.activeLongpoll[taskId]) {
- return TaskRunResult.longpoll();
- }
+ const download = await expectProposalDownload(wex, purchase);
- const download = await expectProposalDownload(ws, purchase);
+ const noAutoRefundOrExpired =
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(purchase.autoRefundDeadline),
+ ),
+ );
- runLongpollAsync(ws, taskId, async (ct) => {
- if (
- !purchase.autoRefundDeadline ||
- AbsoluteTime.isExpired(
- AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(purchase.autoRefundDeadline),
- ),
- )
- ) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return {
- ready: true,
- };
- }
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ { storeNames: ["refundGroups"] },
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- download.contractData.contractTermsHash,
+ const refundedIsLessThanPrice =
+ Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1;
+ const nothingMoreToRefund = !refundedIsLessThanPrice;
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
);
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
- const resp = await ws.http.fetch(requestUrl.href);
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
- // FIXME: Check other status codes!
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
+ // FIXME: Check other status codes!
- if (orderStatus.refund_pending) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return {
- ready: true,
- };
- } else {
- return {
- ready: false,
- };
- }
- });
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
- return TaskRunResult.longpoll();
+ return TaskRunResult.longpollReturnedPending();
}
async function processPurchaseAbortingRefund(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
): Promise<TaskRunResult> {
const proposalId = purchase.proposalId;
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
logger.trace(`processing aborting-refund for proposal ${proposalId}`);
const requestUrl = new URL(
@@ -2462,22 +2993,18 @@ async function processPurchaseAbortingRefund(
throw Error("can't abort, no coins selected");
}
- await ws.db
- .mktx((x) => [x.coins])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(
- payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
+ await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
const abortReq: AbortRequest = {
h_contract: download.contractData.contractTermsHash,
@@ -2486,9 +3013,31 @@ async function processPurchaseAbortingRefund(
logger.trace(`making order abort request to ${requestUrl.href}`);
- const request = await ws.http.postJson(requestUrl.href, abortReq);
+ const abortHttpResp = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: abortReq,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (abortHttpResp.status === HttpStatusCode.NotFound) {
+ const err = await readTalerErrorResponse(abortHttpResp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ ) {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await ctx.transition(async (rec) => {
+ if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ return TransitionResultType.Transition;
+ }
+ return TransitionResultType.Stay;
+ });
+ }
+ }
+
const abortResp = await readSuccessResponseJsonOrThrow(
- request,
+ abortHttpResp,
codecForAbortResponse(),
);
@@ -2514,17 +3063,17 @@ async function processPurchaseAbortingRefund(
),
});
}
- return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
+ return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
}
async function processPurchaseQueryRefund(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
): Promise<TaskRunResult> {
const proposalId = purchase.proposalId;
logger.trace(`processing query-refund for proposal ${proposalId}`);
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
@@ -2535,7 +3084,9 @@ async function processPurchaseQueryRefund(
download.contractData.contractTermsHash,
);
- const resp = await ws.http.fetch(requestUrl.href);
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
@@ -2547,9 +3098,9 @@ async function processPurchaseQueryRefund(
});
if (!orderStatus.refund_pending) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
@@ -2564,18 +3115,19 @@ async function processPurchaseQueryRefund(
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
} else {
const refundAwaiting = Amounts.sub(
Amounts.parseOrThrow(orderStatus.refund_amount),
Amounts.parseOrThrow(orderStatus.refund_taken),
).amount;
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
@@ -2590,19 +3142,18 @@ async function processPurchaseQueryRefund(
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
}
}
async function processPurchaseAcceptRefund(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
-
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
const requestUrl = new URL(
`orders/${download.contractData.orderId}/refund`,
@@ -2611,16 +3162,20 @@ async function processPurchaseAcceptRefund(
logger.trace(`making refund request to ${requestUrl.href}`);
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: download.contractData.contractTermsHash,
+ const request = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: {
+ h_contract: download.contractData.contractTermsHash,
+ },
+ cancellationToken: wex.cancellationToken,
});
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
- codecForMerchantOrderRefundPickupResponse(),
+ codecForWalletRefundResponse(),
);
return await storeRefunds(
- ws,
+ wex,
purchase,
refundResponse.refunds,
RefundReason.AbortRefund,
@@ -2628,7 +3183,7 @@ async function processPurchaseAcceptRefund(
}
export async function startRefundQueryForUri(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
talerUri: string,
): Promise<StartRefundQueryForUriResponse> {
const parsedUri = parseTalerUri(talerUri);
@@ -2638,14 +3193,15 @@ export async function startRefundQueryForUri(
if (parsedUri.type !== TalerUriAction.Refund) {
throw Error("expected taler://refund URI");
}
- const purchaseRecord = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const purchaseRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.get([
parsedUri.merchantBaseUrl,
parsedUri.orderId,
]);
- });
+ },
+ );
if (!purchaseRecord) {
logger.error(
`no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
@@ -2657,23 +3213,20 @@ export async function startRefundQueryForUri(
tag: TransactionType.Payment,
proposalId,
});
- await startQueryRefund(ws, proposalId);
+ await startQueryRefund(wex, proposalId);
return {
transactionId,
};
}
export async function startQueryRefund(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
proposalId: string,
): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
logger.warn(`purchase ${proposalId} does not exist anymore`);
@@ -2687,16 +3240,66 @@ export async function startQueryRefund(
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- ws.workAvailable.trigger();
+ },
+ );
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function computeRefreshRequest(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
+ items: RefundItemRecord[],
+): Promise<CoinRefreshRequest[]> {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+}
+
+/**
+ * Compute the refund item status based on the merchant's response.
+ */
+function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
}
/**
* Store refunds, possibly creating a new refund group.
*/
async function storeRefunds(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
purchase: PurchaseRecord,
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
@@ -2711,62 +3314,25 @@ async function storeRefunds(
const newRefundGroupId = encodeCrock(randomBytes(32));
const now = TalerPreciseTimestamp.now();
- const download = await expectProposalDownload(ws, purchase);
+ const download = await expectProposalDownload(wex, purchase);
const currency = Amounts.currencyOf(download.contractData.amount);
- const getItemStatus = (rf: MerchantCoinRefundStatus) => {
- if (rf.type === "success") {
- return RefundItemStatus.Done;
- } else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
- return RefundItemStatus.Pending;
- } else {
- return RefundItemStatus.Failed;
- }
- }
- };
-
- const result = await ws.db
- .mktx((x) => [
- x.purchases,
- x.refundGroups,
- x.refundItems,
- x.coins,
- x.denominations,
- x.coinAvailability,
- x.refreshGroups,
- ])
- .runReadWrite(async (tx) => {
- const computeRefreshRequest = async (items: RefundItemRecord[]) => {
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const item of items) {
- const coin = await tx.coins.get(item.coinPub);
- if (!coin) {
- throw Error("coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- throw Error("denom not found");
- }
- if (item.status === RefundItemStatus.Done) {
- const refundedAmount = Amounts.sub(
- item.refundAmount,
- denomInfo.feeRefund,
- ).amount;
- refreshCoins.push({
- amount: Amounts.stringify(refundedAmount),
- coinPub: item.coinPub,
- });
- }
- }
- return refreshCoins;
- };
-
+ const result = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
const myPurchase = await tx.purchases.get(purchase.proposalId);
if (!myPurchase) {
logger.warn("purchase group not found anymore");
@@ -2847,9 +3413,13 @@ async function storeRefunds(
// we can compute the raw/effective amounts.
if (newGroup) {
const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
- const refreshCoins = await computeRefreshRequest(newGroupRefunds);
+ const refreshCoins = await computeRefreshRequest(
+ wex,
+ tx,
+ newGroupRefunds,
+ );
const outInfo = await calculateRefreshOutput(
- ws,
+ wex,
tx,
currency,
refreshCoins,
@@ -2867,52 +3437,75 @@ async function storeRefunds(
myPurchase.proposalId,
);
- logger.info(
- `refund groups for proposal ${myPurchase.proposalId}: ${j2s(
- refundGroups,
- )}`,
- );
-
for (const refundGroup of refundGroups) {
- if (refundGroup.status === RefundGroupStatus.Aborted) {
- continue;
- }
- if (refundGroup.status === RefundGroupStatus.Done) {
- continue;
+ switch (refundGroup.status) {
+ case RefundGroupStatus.Aborted:
+ case RefundGroupStatus.Expired:
+ case RefundGroupStatus.Failed:
+ case RefundGroupStatus.Done:
+ continue;
+ case RefundGroupStatus.Pending:
+ break;
+ default:
+ assertUnreachable(refundGroup.status);
}
- const items = await tx.refundItems.indexes.byRefundGroupId.getAll(
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
refundGroup.refundGroupId,
- );
+ ]);
let numPending = 0;
+ let numFailed = 0;
for (const item of items) {
if (item.status === RefundItemStatus.Pending) {
numPending++;
}
+ if (item.status === RefundItemStatus.Failed) {
+ numFailed++;
+ }
}
- logger.info(`refund items pending for refund group: ${numPending}`);
if (numPending === 0) {
- logger.info("refund group is done!");
// We're done for this refund group!
- refundGroup.status = RefundGroupStatus.Done;
+ if (numFailed === 0) {
+ refundGroup.status = RefundGroupStatus.Done;
+ } else {
+ refundGroup.status = RefundGroupStatus.Failed;
+ }
await tx.refundGroups.put(refundGroup);
- const refreshCoins = await computeRefreshRequest(items);
+ const refreshCoins = await computeRefreshRequest(wex, tx, items);
await createRefreshGroup(
- ws,
+ wex,
tx,
Amounts.currencyOf(download.contractData.amount),
refreshCoins,
RefreshReason.Refund,
+ // Since refunds are really just pseudo-transactions,
+ // the originating transaction for the refresh is the payment transaction.
+ constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ }),
);
}
}
const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
if (numPendingItemsTotal === 0) {
if (isAborting) {
myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
} else {
myPurchase.purchaseStatus = PurchaseStatus.Done;
}
+ myPurchase.refundAmountAwaiting = undefined;
}
await tx.purchases.put(myPurchase);
const newTxState = computePayMerchantTransactionState(myPurchase);
@@ -2924,19 +3517,20 @@ async function storeRefunds(
newTxState,
},
};
- });
+ },
+ );
if (!result) {
return TaskRunResult.finished();
}
- notifyTransition(ws, transactionId, result.transitionInfo);
+ notifyTransition(wex, transactionId, result.transitionInfo);
if (result.numPendingItemsTotal > 0) {
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
+ } else {
+ return TaskRunResult.progress();
}
-
- return TaskRunResult.finished();
}
export function computeRefundTransactionState(
@@ -2959,5 +3553,9 @@ export function computeRefundTransactionState(
return {
major: TransactionMajorState.Pending,
};
+ case RefundGroupStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
}
}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
index 1a5dc6e89..bfd39b657 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts
+++ b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -22,49 +22,37 @@ import {
AmountString,
Amounts,
Codec,
- Logger,
+ SelectedProspectiveCoin,
TalerProtocolTimestamp,
buildCodecForObject,
+ checkDbInvariant,
codecForAmountString,
codecForTimestamp,
codecOptional,
} from "@gnu-taler/taler-util";
-import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
-import {
- DenominationRecord,
- PeerPushPaymentCoinSelection,
- ReserveRecord,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import type { SelectedPeerCoin } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
+import { SpendCoinDetails } from "./crypto/cryptoImplementation.js";
+import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js";
import { getTotalRefreshCost } from "./refresh.js";
-import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
-
-const logger = new Logger("operations/peer-to-peer.ts");
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
/**
- * Get information about the coin selected for signatures
- *
- * @param ws
- * @param csel
- * @returns
+ * Get information about the coin selected for signatures.
*/
export async function queryCoinInfosForSelection(
- ws: InternalWalletState,
- csel: PeerPushPaymentCoinSelection,
+ wex: WalletExecutionContext,
+ csel: DbPeerPushPaymentCoinSelection,
): Promise<SpendCoinDetails[]> {
let infos: SpendCoinDetails[] = [];
- await ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
for (let i = 0; i < csel.coinPubs.length; i++) {
const coin = await tx.coins.get(csel.coinPubs[i]);
if (!coin) {
throw Error("coin not found anymore");
}
- const denom = await ws.getDenomInfo(
- ws,
+ const denom = await getDenomInfo(
+ wex,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
@@ -81,57 +69,48 @@ export async function queryCoinInfosForSelection(
contribution: csel.contributions[i],
});
}
- });
+ },
+ );
return infos;
}
export async function getTotalPeerPaymentCost(
- ws: InternalWalletState,
- pcs: SelectedPeerCoin[],
+ wex: WalletExecutionContext,
+ pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs[0].contribution);
- return ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
const costs: AmountJson[] = [];
for (let i = 0; i < pcs.length; i++) {
- const coin = await tx.coins.get(pcs[i].coinPub);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
+ const denomInfo = await getDenomInfo(
+ wex,
tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
);
if (!denomInfo) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
const amountLeft = Amounts.sub(
denomInfo.value,
pcs[i].contribution,
).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
denomInfo,
amountLeft,
- ws.config.testing.denomselAllowLate,
);
costs.push(Amounts.parseOrThrow(pcs[i].contribution));
costs.push(refreshCost);
}
const zero = Amounts.zeroOfAmount(pcs[0].contribution);
return Amounts.sum([zero, ...costs]).amount;
- });
+ },
+ );
}
interface ExchangePurseStatus {
@@ -148,18 +127,18 @@ export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
.build("ExchangePurseStatus");
export async function getMergeReserveInfo(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: {
exchangeBaseUrl: string;
},
): Promise<ReserveRecord> {
// 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 newReservePair = await wex.cryptoApi.createEddsaKeypair({});
- const mergeReserveRecord: ReserveRecord = await ws.db
- .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
- .runReadWrite(async (tx) => {
+ const mergeReserveRecord: ReserveRecord = await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "reserves"] },
+ async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReserveRowId != null) {
@@ -177,7 +156,8 @@ export async function getMergeReserveInfo(
ex.currentMergeReserveRowId = reserve.rowId;
await tx.exchanges.put(ex);
return reserve;
- });
+ },
+ );
return mergeReserveRecord;
}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
index 292116bd5..840c244d0 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -17,7 +17,6 @@
import {
AbsoluteTime,
Amounts,
- CancellationToken,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
ContractTermsUtil,
@@ -33,12 +32,15 @@ import {
TalerProtocolTimestamp,
TalerUriAction,
TransactionAction,
+ TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
WalletAccountMergeFlags,
WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
codecForAny,
codecForWalletKycUuid,
encodeCrock,
@@ -48,11 +50,16 @@ import {
stringifyTalerUri,
talerPaytoFromExchangeReserve,
} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
import {
KycPendingInfo,
KycUserType,
@@ -63,19 +70,8 @@ import {
timestampOptionalPreciseFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
- fetchFreshExchange,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- LongpollResult,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- runLongpollAsync,
-} from "./common.js";
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
import {
codecForExchangePurseStatus,
getMergeReserveInfo,
@@ -83,29 +79,303 @@ import {
import {
constructTransactionIdentifier,
notifyTransition,
- stopLongpolling,
} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
import {
getExchangeWithdrawalInfo,
internalCreateWithdrawalGroup,
+ waitWithdrawalFinal,
} from "./withdraw.js";
const logger = new Logger("pay-peer-pull-credit.ts");
+export class PeerPullCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, pursePub } = this;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] },
+ async (tx) => {
+ const pullIni = await tx.peerPullCredit.get(pursePub);
+ if (!pullIni) {
+ return;
+ }
+ if (pullIni.withdrawalGroupId) {
+ const withdrawalGroupId = pullIni.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPullCredit.delete(pursePub);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
+ });
+ },
+ );
+
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ newStatus =
+ PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.Aborted:
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ newStatus = PeerPullPaymentCreditStatus.PendingReady;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ throw Error("can't abort anymore");
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
async function queryPurseForPeerPullCredit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
- cancellationToken: CancellationToken,
-): Promise<LongpollResult> {
+): Promise<TaskRunResult> {
const purseDepositUrl = new URL(
`purses/${pullIni.pursePub}/deposit`,
pullIni.exchangeBaseUrl,
);
purseDepositUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying purse status via ${purseDepositUrl.href}`);
- const resp = await ws.http.fetch(purseDepositUrl.href, {
+ const resp = await wex.http.fetch(purseDepositUrl.href, {
timeout: { d_ms: 60000 },
- cancellationToken,
+ cancellationToken: wex.cancellationToken,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
@@ -117,9 +387,9 @@ async function queryPurseForPeerPullCredit(
switch (resp.status) {
case HttpStatusCode.Gone: {
// Exchange says that purse doesn't exist anymore => expired!
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
logger.warn("peerPullCredit not found anymore");
@@ -132,12 +402,14 @@ async function queryPurseForPeerPullCredit(
await tx.peerPullCredit.put(finPi);
const newTxState = computePeerPullCreditTransactionState(finPi);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return { ready: true };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
}
case HttpStatusCode.NotFound:
- return { ready: false };
+ // FIXME: Maybe check error code? 404 could also mean something else.
+ return TaskRunResult.longpollReturnedPending();
}
const result = await readSuccessResponseJsonOrThrow(
@@ -151,20 +423,21 @@ async function queryPurseForPeerPullCredit(
if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
logger.info("purse not ready yet (no deposit)");
- return { ready: false };
+ return TaskRunResult.backoff();
}
- const reserve = await ws.db
- .mktx((x) => [x.reserves])
- .runReadOnly(async (tx) => {
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
return await tx.reserves.get(pullIni.mergeReserveRowId);
- });
+ },
+ );
if (!reserve) {
throw Error("reserve for peer pull credit not found in wallet DB");
}
- await internalCreateWithdrawalGroup(ws, {
+ await internalCreateWithdrawalGroup(wex, {
amount: Amounts.parseOrThrow(pullIni.amount),
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPullCredit,
@@ -178,9 +451,9 @@ async function queryPurseForPeerPullCredit(
pub: reserve.reservePub,
},
});
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
logger.warn("peerPullCredit not found anymore");
@@ -193,15 +466,14 @@ async function queryPurseForPeerPullCredit(
await tx.peerPullCredit.put(finPi);
const newTxState = computePeerPullCreditTransactionState(finPi);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return {
- ready: true,
- };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
}
async function longpollKycStatus(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
pursePub: string,
exchangeUrl: string,
kycInfo: KycPendingInfo,
@@ -211,65 +483,52 @@ async function longpollKycStatus(
tag: TransactionType.PeerPullCredit,
pursePub,
});
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
});
-
- runLongpollAsync(ws, retryTag, async (ct) => {
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const peerIni = await tx.peerPullCredit.get(pursePub);
+ if (!peerIni) {
+ return;
+ }
+ if (
+ peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
+ ) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerIni);
+ peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ const newTxState = computePeerPullCreditTransactionState(peerIni);
+ await tx.peerPullCredit.put(peerIni);
+ return { oldTxState, newTxState };
+ },
);
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken: ct,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
- const peerIni = await tx.peerPullCredit.get(pursePub);
- if (!peerIni) {
- return;
- }
- if (
- peerIni.status !==
- PeerPullPaymentCreditStatus.PendingMergeKycRequired
- ) {
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(peerIni);
- peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
- const newTxState = computePeerPullCreditTransactionState(peerIni);
- await tx.peerPullCredit.put(peerIni);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return { ready: true };
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- return { ready: false };
- } else {
- throw Error(
- `unexpected response from kyc-check (${kycStatusRes.status})`,
- );
- }
- });
- return {
- type: TaskRunResultType.Longpoll,
- };
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
}
async function processPeerPullCreditAbortingDeletePurse(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerPullIni: PeerPullCreditRecord,
): Promise<TaskRunResult> {
const { pursePub, pursePriv } = peerPullIni;
@@ -278,27 +537,30 @@ async function processPeerPullCreditAbortingDeletePurse(
pursePub,
});
- const sigResp = await ws.cryptoApi.signDeletePurse({
+ const sigResp = await wex.cryptoApi.signDeletePurse({
pursePriv,
});
const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
- const resp = await ws.http.fetch(purseUrl.href, {
+ const resp = await wex.http.fetch(purseUrl.href, {
method: "DELETE",
headers: {
"taler-purse-signature": sigResp.sig,
},
+ cancellationToken: wex.cancellationToken,
});
logger.info(`deleted purse with response status ${resp.status}`);
- const transitionInfo = await ws.db
- .mktx((x) => [
- x.peerPullCredit,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPullCredit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
const ppiRec = await tx.peerPullCredit.get(pursePub);
if (!ppiRec) {
return undefined;
@@ -314,28 +576,30 @@ async function processPeerPullCreditAbortingDeletePurse(
oldTxState,
newTxState,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
}
async function handlePeerPullCreditWithdrawing(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
): Promise<TaskRunResult> {
if (!pullIni.withdrawalGroupId) {
throw Error("invalid db state (withdrawing, but no withdrawal group ID");
}
+ await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
const wgId = pullIni.withdrawalGroupId;
let finished: boolean = false;
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit, x.withdrawalGroups])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit", "withdrawalGroups"] },
+ async (tx) => {
const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!ppi) {
finished = true;
@@ -364,37 +628,40 @@ async function handlePeerPullCreditWithdrawing(
oldTxState,
newTxState,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
if (finished) {
return TaskRunResult.finished();
} else {
// FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
}
}
async function handlePeerPullCreditCreatePurse(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
): Promise<TaskRunResult> {
const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
const pursePub = pullIni.pursePub;
- const mergeReserve = await ws.db
- .mktx((x) => [x.reserves])
- .runReadOnly(async (tx) => {
+ const mergeReserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
return tx.reserves.get(pullIni.mergeReserveRowId);
- });
+ },
+ );
if (!mergeReserve) {
throw Error("merge reserve for peer pull payment not found in database");
}
- const contractTermsRecord = await ws.db
- .mktx((x) => [x.contractTerms])
- .runReadOnly(async (tx) => {
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
return tx.contractTerms.get(pullIni.contractTermsHash);
- });
+ },
+ );
if (!contractTermsRecord) {
throw Error("contract terms for peer pull payment not found in database");
@@ -407,7 +674,7 @@ async function handlePeerPullCreditCreatePurse(
mergeReserve.reservePub,
);
- const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
+ const econtractResp = await wex.cryptoApi.encryptContractForDeposit({
contractPriv: pullIni.contractPriv,
contractPub: pullIni.contractPub,
contractTerms: contractTermsRecord.contractTermsRaw,
@@ -419,7 +686,7 @@ async function handlePeerPullCreditCreatePurse(
const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
const purseExpiration = contractTerms.purse_expiration;
- const sigRes = await ws.cryptoApi.signReservePurseCreate({
+ const sigRes = await wex.cryptoApi.signReservePurseCreate({
contractTermsHash: pullIni.contractTermsHash,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
mergePriv: pullIni.mergePriv,
@@ -455,16 +722,17 @@ async function handlePeerPullCreditCreatePurse(
pullIni.exchangeBaseUrl,
);
- const httpResp = await ws.http.fetch(reservePurseMergeUrl.href, {
+ const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, {
method: "POST",
body: reservePurseReqBody,
+ cancellationToken: wex.cancellationToken,
});
if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
const respJson = await httpResp.json();
const kycPending = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPullCreditKycRequired(ws, pullIni, kycPending);
+ return processPeerPullCreditKycRequired(wex, pullIni, kycPending);
}
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
@@ -476,9 +744,9 @@ async function handlePeerPullCreditCreatePurse(
pursePub: pullIni.pursePub,
});
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
const pi2 = await tx.peerPullCredit.get(pursePub);
if (!pi2) {
return;
@@ -488,21 +756,22 @@ async function handlePeerPullCreditCreatePurse(
await tx.peerPullCredit.put(pi2);
const newTxState = computePeerPullCreditTransactionState(pi2);
return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.finished();
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
}
export async function processPeerPullCredit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
pursePub: string,
): Promise<TaskRunResult> {
- const pullIni = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadOnly(async (tx) => {
+ const pullIni = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
return tx.peerPullCredit.get(pursePub);
- });
+ },
+ );
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
@@ -512,14 +781,6 @@ export async function processPeerPullCredit(
pursePub,
});
- // We're already running!
- if (ws.activeLongpoll[retryTag]) {
- logger.info("peer-pull-credit already in long-polling, returning!");
- return {
- type: TaskRunResultType.Longpoll,
- };
- }
-
logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
switch (pullIni.status) {
@@ -527,21 +788,13 @@ export async function processPeerPullCredit(
return TaskRunResult.finished();
}
case PeerPullPaymentCreditStatus.PendingReady:
- runLongpollAsync(ws, retryTag, async (cancellationToken) =>
- queryPurseForPeerPullCredit(ws, pullIni, cancellationToken),
- );
- logger.trace(
- "returning early from processPeerPullCredit for long-polling in background",
- );
- return {
- type: TaskRunResultType.Longpoll,
- };
+ return queryPurseForPeerPullCredit(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
if (!pullIni.kycInfo) {
throw Error("invalid state, kycInfo required");
}
return await longpollKycStatus(
- ws,
+ wex,
pursePub,
pullIni.exchangeBaseUrl,
pullIni.kycInfo,
@@ -549,11 +802,11 @@ export async function processPeerPullCredit(
);
}
case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return handlePeerPullCreditCreatePurse(ws, pullIni);
+ return handlePeerPullCreditCreatePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return await processPeerPullCreditAbortingDeletePurse(ws, pullIni);
+ return await processPeerPullCreditAbortingDeletePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return handlePeerPullCreditWithdrawing(ws, pullIni);
+ return handlePeerPullCreditWithdrawing(wex, pullIni);
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
@@ -571,7 +824,7 @@ export async function processPeerPullCredit(
}
async function processPeerPullCreditKycRequired(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerIni: PeerPullCreditRecord,
kycPending: WalletKycUuid,
): Promise<TaskRunResult> {
@@ -588,8 +841,9 @@ async function processPeerPullCreditKycRequired(
);
logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
+ const kycStatusRes = await wex.http.fetch(url.href, {
method: "GET",
+ cancellationToken: wex.cancellationToken,
});
if (
@@ -599,13 +853,13 @@ async function processPeerPullCreditKycRequired(
kycStatusRes.status === HttpStatusCode.NoContent
) {
logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.finished();
+ return TaskRunResult.backoff();
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
const peerInc = await tx.peerPullCredit.get(pursePub);
if (!peerInc) {
return {
@@ -637,9 +891,10 @@ async function processPeerPullCreditKycRequired(
transitionInfo: { oldTxState, newTxState },
result: res,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.pending();
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
@@ -649,7 +904,7 @@ async function processPeerPullCreditKycRequired(
* Check fees and available exchanges for a peer push payment initiation.
*/
export async function checkPeerPullPaymentInitiation(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: CheckPeerPullCreditRequest,
): Promise<CheckPeerPullCreditResponse> {
// FIXME: We don't support exchanges with purse fees yet.
@@ -663,7 +918,7 @@ export async function checkPeerPullPaymentInitiation(
if (req.exchangeBaseUrl) {
exchangeUrl = req.exchangeBaseUrl;
} else {
- exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
+ exchangeUrl = await getPreferredExchangeForCurrency(wex, currency);
}
if (!exchangeUrl) {
@@ -673,7 +928,7 @@ export async function checkPeerPullPaymentInitiation(
logger.trace(`found ${exchangeUrl} as preferred exchange`);
const wi = await getExchangeWithdrawalInfo(
- ws,
+ wex,
exchangeUrl,
Amounts.parseOrThrow(req.amount),
undefined,
@@ -698,14 +953,14 @@ export async function checkPeerPullPaymentInitiation(
* Find a preferred exchange based on when we withdrew last from this exchange.
*/
async function getPreferredExchangeForCurrency(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
currency: string,
): Promise<string | undefined> {
// Find an exchange with the matching currency.
// Prefer exchanges with the most recent withdrawal.
- const url = await ws.db
- .mktx((x) => [x.exchanges])
- .runReadOnly(async (tx) => {
+ const url = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
let candidate = undefined;
for (const e of exchanges) {
@@ -740,7 +995,8 @@ async function getPreferredExchangeForCurrency(
return candidate.baseUrl;
}
return undefined;
- });
+ },
+ );
return url;
}
@@ -748,7 +1004,7 @@ async function getPreferredExchangeForCurrency(
* Initiate a peer pull payment.
*/
export async function initiatePeerPullPayment(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: InitiatePeerPullCreditRequest,
): Promise<InitiatePeerPullCreditResponse> {
const currency = Amounts.currencyOf(req.partialContractTerms.amount);
@@ -756,7 +1012,7 @@ export async function initiatePeerPullPayment(
if (req.exchangeBaseUrl) {
maybeExchangeBaseUrl = req.exchangeBaseUrl;
} else {
- maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
+ maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency);
}
if (!maybeExchangeBaseUrl) {
@@ -765,20 +1021,20 @@ export async function initiatePeerPullPayment(
const exchangeBaseUrl = maybeExchangeBaseUrl;
- await fetchFreshExchange(ws, exchangeBaseUrl);
+ await fetchFreshExchange(wex, exchangeBaseUrl);
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
exchangeBaseUrl: exchangeBaseUrl,
});
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
const contractTerms = req.partialContractTerms;
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
@@ -788,7 +1044,7 @@ export async function initiatePeerPullPayment(
const contractEncNonce = encodeCrock(getRandomBytes(24));
const wi = await getExchangeWithdrawalInfo(
- ws,
+ wex,
exchangeBaseUrl,
Amounts.parseOrThrow(req.partialContractTerms.amount),
undefined,
@@ -796,9 +1052,9 @@ export async function initiatePeerPullPayment(
const mergeTimestamp = TalerPreciseTimestamp.now();
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit, x.contractTerms])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit", "contractTerms"] },
+ async (tx) => {
const ppi: PeerPullCreditRecord = {
amount: req.partialContractTerms.amount,
contractTermsHash: hContractTerms,
@@ -826,285 +1082,30 @@ export async function initiatePeerPullPayment(
h: hContractTerms,
});
return { oldTxState, newTxState };
- });
+ },
+ );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pursePair.pub,
- });
+ const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub);
+
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
// The pending-incoming balance has changed.
- ws.notify({
+ wex.ws.notify({
type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
+ hintTransactionId: ctx.transactionId,
});
- notifyTransition(ws, transactionId, transitionInfo);
-
- ws.workAvailable.trigger();
-
return {
talerUri: stringifyTalerUri({
type: TalerUriAction.PayPull,
exchangeBaseUrl: exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
}),
- transactionId,
+ transactionId: ctx.transactionId,
};
}
-export async function suspendPeerPullCreditTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPullCreditTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- throw Error("can't abort anymore");
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPullCreditTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.Failed;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function resumePeerPullCreditTransaction(
- ws: InternalWalletState,
- pursePub: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPullCredit])
- .runReadWrite(async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.Aborted:
- break;
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.SuspendedReady:
- newStatus = PeerPullPaymentCreditStatus.PendingReady;
- break;
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
export function computePeerPullCreditTransactionState(
pullCreditRecord: PeerPullCreditRecord,
): TransactionState {
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
new file mode 100644
index 000000000..0355b58ad
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -0,0 +1,1019 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the peer-pull-debit transaction, i.e.
+ * paying for an invoice the wallet received from another wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptPeerPullPaymentResponse,
+ Amounts,
+ CoinRefreshRequest,
+ ConfirmPeerPullDebitRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PeerContractTerms,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkLogicInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ parsePayPullUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ TransitionResultType,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ RefreshOperationStatus,
+ WalletStoresV1,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+/**
+ * Common context for a peer-pull-debit transaction.
+ */
+export class PeerPullDebitTransactionContext implements TransactionContext {
+ wex: WalletExecutionContext;
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+ peerPullDebitId: string;
+
+ constructor(wex: WalletExecutionContext, peerPullDebitId: string) {
+ this.wex = wex;
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.peerPullDebitId = peerPullDebitId;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const transactionId = this.transactionId;
+ const ws = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const taskId = this.taskId;
+ const transactionId = this.transactionId;
+ const wex = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pullDebitRec) {
+ logger.warn(`peer pull debit ${peerPullDebitId} not found`);
+ return;
+ }
+ let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+ switch (pullDebitRec.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ break;
+ case PeerPullDebitRecordStatus.Done:
+ break;
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
+ break;
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ break;
+ case PeerPullDebitRecordStatus.Aborted:
+ break;
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
+ break;
+ case PeerPullDebitRecordStatus.Failed:
+ break;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ break;
+ default:
+ assertUnreachable(pullDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ pullDebitRec.status = newStatus;
+ const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ await tx.peerPullDebit.put(pullDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.Aborted:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.Failed:
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.Done:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ // FIXME: Should we also abort the corresponding refresh session?!
+ pi.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ default:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transitionExtra(
+ {
+ extraStores: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (pi, tx) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ break;
+ default:
+ return TransitionResultType.Stay;
+ }
+ const currency = Amounts.currencyOf(pi.totalCostEstimated);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!pi.coinSel) {
+ throw Error("invalid db state");
+ }
+
+ for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: pi.coinSel.contributions[i],
+ coinPub: pi.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ctx.wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ pi.abortRefreshGroupId = refresh.refreshGroupId;
+ return TransitionResultType.Transition;
+ },
+ );
+ }
+
+ async transition(
+ f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PeerPullPaymentIncomingRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["peerPullDebit", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const wex = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", ...extraStores] },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
+ if (!pi) {
+ throw Error("peer pull payment not found anymore");
+ }
+ const oldTxState = computePeerPullDebitTransactionState(pi);
+ const res = await f(pi, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, this.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+async function handlePurseCreationConflict(
+ ctx: PeerPullDebitTransactionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const ws = ctx.wex;
+ const errResp = await readTalerErrorResponse(resp);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const sel = peerPullInc.coinSel;
+ if (!sel) {
+ throw Error("invalid state (coin selection expected)");
+ }
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "prospective":
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending (blocked on refresh)",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
+ const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.SuspendedDeposit: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPullDebit.put(myPpi);
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullDebitPendingDeposit(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullInc.peerPullDebitId,
+ );
+
+ const pursePub = peerPullInc.pursePub;
+
+ const coinSel = peerPullInc.coinSel;
+
+ if (!coinSel) {
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (locked behind refresh)");
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ return false;
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return false;
+ }
+ if (pi.coinSel) {
+ return false;
+ }
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ await tx.peerPullDebit.put(pi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPullInc.exchangeBaseUrl,
+ );
+
+ // FIXME: We could skip batches that we've already submitted.
+
+ const coins = await queryCoinInfosForSelection(wex, coinSel);
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
+ });
+
+ const batchCoins = coins.slice(i, i + batchSize);
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins: batchCoins,
+ });
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+ }
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+ continue;
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ }
+
+ // All batches succeeded, we can transition!
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResultType.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResultType.Transition;
+ });
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullDebitAbortingRefresh(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPullDebitRecordStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPullDebitRecordStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPullDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPullDebitTransactionState(newDg);
+ await tx.peerPullDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullDebit(
+ wex: WalletExecutionContext,
+ peerPullDebitId: string,
+): Promise<TaskRunResult> {
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+ if (!peerPullInc) {
+ throw Error("peer pull debit not found");
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ }
+ return TaskRunResult.finished();
+}
+
+export async function confirmPeerPullDebit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+ let peerPullDebitId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
+ throw Error("invalid peer-pull-debit transaction identifier");
+ }
+ peerPullDebitId = parsedTx.peerPullDebitId;
+
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+
+ if (!peerPullInc) {
+ throw Error(
+ `can't accept unknown incoming p2p pull payment (${req.transactionId})`,
+ );
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ throw Error();
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
+ return;
+ }
+ if (coinSelRes.type == "success") {
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ await tx.peerPullDebit.put(pi);
+ },
+ );
+
+ const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
+
+ const transactionId = ctx.transactionId;
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId,
+ };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+ const uri = parsePayPullUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-pull URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ const peerPullDebitRecord =
+ await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!peerPullDebitRecord) {
+ return;
+ }
+ const contractTerms = await tx.contractTerms.get(
+ peerPullDebitRecord.contractTermsHash,
+ );
+ if (!contractTerms) {
+ return;
+ }
+ return { peerPullDebitRecord, contractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.peerPullDebitRecord.amount,
+ amountRaw: existing.peerPullDebitRecord.amount,
+ amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
+ contractTerms: existing.contractTerms.contractTermsRaw,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ }),
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForDeposit({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ const peerPullDebitId = encodeCrock(getRandomBytes(32));
+
+ let contractTerms: PeerContractTerms;
+
+ if (dec.contractTerms) {
+ contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ // FIXME: Check that the purseStatus balance matches contract terms amount
+ } else {
+ // FIXME: In this case, where do we get the purse expiration from?!
+ // https://bugs.gnunet.org/view.php?id=7706
+ throw Error("pull payments without contract terms not supported yet");
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ // FIXME: Why don't we compute the totalCost here?!
+
+ const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: contractTerms,
+ }),
+ await tx.peerPullDebit.add({
+ peerPullDebitId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePub: pursePub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ amount: contractTerms.amount,
+ status: PeerPullDebitRecordStatus.DialogProposed,
+ totalCostEstimated: Amounts.stringify(totalAmount),
+ });
+ },
+ );
+
+ return {
+ amount: contractTerms.amount,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: contractTerms.amount,
+ contractTerms: contractTerms,
+ peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: peerPullDebitId,
+ }),
+ };
+}
+
+export function computePeerPullDebitTransactionState(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionState {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPullDebitRecordStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ }
+}
+
+export function computePeerPullDebitTransactionActions(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return [];
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullDebitRecordStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
index 78263c4c3..93f1a63a7 100644
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -30,12 +30,15 @@ import {
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TransactionAction,
+ TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
WalletAccountMergeFlags,
WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
codecForAny,
codecForExchangeGetContractResponse,
codecForPeerContractTerms,
@@ -51,24 +54,23 @@ import {
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
- InternalWalletState,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
KycPendingInfo,
KycUserType,
- PeerPushPaymentIncomingRecord,
PeerPushCreditStatus,
- PendingTaskType,
+ PeerPushPaymentIncomingRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- runLongpollAsync,
-} from "./common.js";
+} from "./db.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
codecForExchangePurseStatus,
@@ -79,18 +81,281 @@ import {
constructTransactionIdentifier,
notifyTransition,
parseTransactionIdentifier,
- stopLongpolling,
} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
import {
+ PerformCreateWithdrawalGroupResult,
getExchangeWithdrawalInfo,
internalPerformCreateWithdrawalGroup,
internalPrepareCreateWithdrawalGroup,
+ waitWithdrawalFinal,
} from "./withdraw.js";
const logger = new Logger("pay-peer-push-credit.ts");
+export class PeerPushCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public peerPushCreditId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, peerPushCreditId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] },
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) {
+ return;
+ }
+ if (pushInc.withdrawalGroupId) {
+ const withdrawalGroupId = pushInc.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPushCredit.delete(peerPushCreditId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
+ });
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.SuspendedMerge;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ // FIXME: Suspend internal withdrawal transaction!
+ newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Done:
+ break;
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ newStatus = PeerPushCreditStatus.PendingMerge;
+ break;
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ // FIXME: resume underlying "internal-withdrawal" transaction.
+ newStatus = PeerPushCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ // Already in a final state.
+ return;
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
export async function preparePeerPushCredit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: PreparePeerPushCreditRequest,
): Promise<PreparePeerPushCreditResponse> {
const uri = parsePayPushUri(req.talerUri);
@@ -99,9 +364,9 @@ export async function preparePeerPushCredit(
throw Error("got invalid taler://pay-push URI");
}
- const existing = await ws.db
- .mktx((x) => [x.contractTerms, x.peerPushCredit])
- .runReadOnly(async (tx) => {
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
const existingPushInc =
await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
uri.exchangeBaseUrl,
@@ -122,7 +387,8 @@ export async function preparePeerPushCredit(
existingContractTermsRec.contractTermsRaw,
);
return { existingPushInc, existingContractTerms };
- });
+ },
+ );
if (existing) {
return {
@@ -141,14 +407,14 @@ export async function preparePeerPushCredit(
const exchangeBaseUrl = uri.exchangeBaseUrl;
- await fetchFreshExchange(ws, exchangeBaseUrl);
+ await fetchFreshExchange(wex, exchangeBaseUrl);
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
@@ -157,7 +423,7 @@ export async function preparePeerPushCredit(
const pursePub = contractResp.purse_pub;
- const dec = await ws.cryptoApi.decryptContractForMerge({
+ const dec = await wex.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract,
contractPriv: contractPriv,
pursePub: pursePub,
@@ -165,7 +431,7 @@ export async function preparePeerPushCredit(
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
@@ -187,15 +453,15 @@ export async function preparePeerPushCredit(
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const wi = await getExchangeWithdrawalInfo(
- ws,
+ wex,
exchangeBaseUrl,
Amounts.parseOrThrow(purseStatus.balance),
undefined,
);
- const transitionInfo = await ws.db
- .mktx((x) => [x.contractTerms, x.peerPushCredit])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
const rec: PeerPushPaymentIncomingRecord = {
peerPushCreditId,
contractPriv: contractPriv,
@@ -225,16 +491,17 @@ export async function preparePeerPushCredit(
},
newTxState,
} satisfies TransitionInfo;
- });
+ },
+ );
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId,
});
- notifyTransition(ws, transactionId, transitionInfo);
+ notifyTransition(wex, transactionId, transitionInfo);
- ws.notify({
+ wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
@@ -251,7 +518,7 @@ export async function preparePeerPushCredit(
}
async function longpollKycStatus(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerPushCreditId: string,
exchangeUrl: string,
kycInfo: KycPendingInfo,
@@ -261,62 +528,51 @@ async function longpollKycStatus(
tag: TransactionType.PeerPushCredit,
peerPushCreditId,
});
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
});
-
- runLongpollAsync(ws, retryTag, async (ct) => {
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ return { oldTxState, newTxState };
+ },
);
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken: ct,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushCredit])
- .runReadWrite(async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
- return;
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.status = PeerPushCreditStatus.PendingMerge;
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- await tx.peerPushCredit.put(peerInc);
- return { oldTxState, newTxState };
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return { ready: true };
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- return { ready: false };
- } else {
- throw Error(
- `unexpected response from kyc-check (${kycStatusRes.status})`,
- );
- }
- });
- return {
- type: TaskRunResultType.Longpoll,
- };
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
}
async function processPeerPushCreditKycRequired(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerInc: PeerPushPaymentIncomingRecord,
kycPending: WalletKycUuid,
): Promise<TaskRunResult> {
@@ -333,8 +589,9 @@ async function processPeerPushCreditKycRequired(
);
logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
+ const kycStatusRes = await wex.http.fetch(url.href, {
method: "GET",
+ cancellationToken: wex.cancellationToken,
});
if (
@@ -348,9 +605,9 @@ async function processPeerPushCreditKycRequired(
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db
- .mktx((x) => [x.peerPushCredit])
- .runReadWrite(async (tx) => {
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
return {
@@ -382,8 +639,9 @@ async function processPeerPushCreditKycRequired(
transitionInfo: { oldTxState, newTxState },
result: res,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
return result;
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
@@ -391,7 +649,7 @@ async function processPeerPushCreditKycRequired(
}
async function handlePendingMerge(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerInc: PeerPushPaymentIncomingRecord,
contractTerms: PeerContractTerms,
): Promise<TaskRunResult> {
@@ -403,7 +661,7 @@ async function handlePendingMerge(
const amount = Amounts.parseOrThrow(contractTerms.amount);
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
exchangeBaseUrl: peerInc.exchangeBaseUrl,
});
@@ -414,7 +672,7 @@ async function handlePendingMerge(
mergeReserveInfo.reservePub,
);
- const sigRes = await ws.cryptoApi.signPurseMerge({
+ const sigRes = await wex.cryptoApi.signPurseMerge({
contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
mergePriv: peerInc.mergePriv,
@@ -439,7 +697,7 @@ async function handlePendingMerge(
reserve_sig: sigRes.accountSig,
};
- const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, {
+ const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, {
method: "POST",
body: mergeReq,
});
@@ -448,7 +706,7 @@ async function handlePendingMerge(
const respJson = await mergeHttpResp.json();
const kycPending = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPushCreditKycRequired(ws, peerInc, kycPending);
+ return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
}
logger.trace(`merge request: ${j2s(mergeReq)}`);
@@ -458,7 +716,7 @@ async function handlePendingMerge(
);
logger.trace(`merge response: ${j2s(res)}`);
- const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, {
+ const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
amount,
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPushCredit,
@@ -472,33 +730,36 @@ async function handlePendingMerge(
},
});
- const txRes = await ws.db
- .mktx((x) => [
- x.contractTerms,
- x.peerPushCredit,
- x.withdrawalGroups,
- x.reserves,
- x.exchanges,
- x.exchangeDetails,
- ])
- .runReadWrite(async (tx) => {
+ const txRes = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ },
+ async (tx) => {
const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
return undefined;
}
- let withdrawalTransition: TransitionInfo | undefined;
const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
+ undefined;
switch (peerInc.status) {
case PeerPushCreditStatus.PendingMerge:
case PeerPushCreditStatus.PendingMergeKycRequired: {
peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
- const wgRes = await internalPerformCreateWithdrawalGroup(
- ws,
+ wgCreateRes = await internalPerformCreateWithdrawalGroup(
+ wex,
tx,
withdrawalGroupPrep,
);
- withdrawalTransition = wgRes.transitionInfo;
- peerInc.withdrawalGroupId = wgRes.withdrawalGroup.withdrawalGroupId;
+ peerInc.withdrawalGroupId =
+ wgCreateRes.withdrawalGroup.withdrawalGroupId;
break;
}
}
@@ -506,35 +767,41 @@ async function handlePendingMerge(
const newTxState = computePeerPushCreditTransactionState(peerInc);
return {
peerPushCreditTransition: { oldTxState, newTxState },
- withdrawalTransition,
+ wgCreateRes,
};
- });
+ },
+ );
+ // Transaction was committed, now we can emit notifications.
+ if (txRes?.wgCreateRes?.exchangeNotif) {
+ wex.ws.notify(txRes.wgCreateRes.exchangeNotif);
+ }
notifyTransition(
- ws,
+ wex,
withdrawalGroupPrep.transactionId,
- txRes?.withdrawalTransition,
+ txRes?.wgCreateRes?.transitionInfo,
);
- notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition);
+ notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition);
- return TaskRunResult.finished();
+ return TaskRunResult.backoff();
}
async function handlePendingWithdrawing(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerInc: PeerPushPaymentIncomingRecord,
): Promise<TaskRunResult> {
if (!peerInc.withdrawalGroupId) {
throw Error("invalid db state (withdrawing, but no withdrawal group ID");
}
+ await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: peerInc.peerPushCreditId,
});
const wgId = peerInc.withdrawalGroupId;
let finished: boolean = false;
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushCredit, x.withdrawalGroups])
- .runReadWrite(async (tx) => {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit", "withdrawalGroups"] },
+ async (tx) => {
const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
if (!ppi) {
finished = true;
@@ -563,25 +830,26 @@ async function handlePendingWithdrawing(
oldTxState,
newTxState,
};
- });
- notifyTransition(ws, transactionId, transitionInfo);
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
if (finished) {
return TaskRunResult.finished();
} else {
// FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.pending();
+ return TaskRunResult.backoff();
}
}
export async function processPeerPushCredit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
peerPushCreditId: string,
): Promise<TaskRunResult> {
let peerInc: PeerPushPaymentIncomingRecord | undefined;
let contractTerms: PeerContractTerms | undefined;
- await ws.db
- .mktx((x) => [x.contractTerms, x.peerPushCredit])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
return;
@@ -591,9 +859,8 @@ export async function processPeerPushCredit(
contractTerms = ctRec.contractTermsRaw;
}
await tx.peerPushCredit.put(peerInc);
- });
-
- checkDbInvariant(!!contractTerms);
+ },
+ );
if (!peerInc) {
throw Error(
@@ -601,13 +868,19 @@ export async function processPeerPushCredit(
);
}
+ logger.info(
+ `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
+ );
+
+ checkDbInvariant(!!contractTerms);
+
switch (peerInc.status) {
case PeerPushCreditStatus.PendingMergeKycRequired: {
if (!peerInc.kycInfo) {
throw Error("invalid state, kycInfo required");
}
return await longpollKycStatus(
- ws,
+ wex,
peerPushCreditId,
peerInc.exchangeBaseUrl,
peerInc.kycInfo,
@@ -616,10 +889,10 @@ export async function processPeerPushCredit(
}
case PeerPushCreditStatus.PendingMerge:
- return handlePendingMerge(ws, peerInc, contractTerms);
+ return handlePendingMerge(wex, peerInc, contractTerms);
case PeerPushCreditStatus.PendingWithdrawing:
- return handlePendingWithdrawing(ws, peerInc);
+ return handlePendingWithdrawing(wex, peerInc);
default:
return TaskRunResult.finished();
@@ -627,29 +900,25 @@ export async function processPeerPushCredit(
}
export async function confirmPeerPushCredit(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: ConfirmPeerPushCreditRequest,
): Promise<AcceptPeerPushPaymentResponse> {
let peerInc: PeerPushPaymentIncomingRecord | undefined;
let peerPushCreditId: string;
- if (req.peerPushCreditId) {
- peerPushCreditId = req.peerPushCreditId;
- } else if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
- if (parsedTx.tag !== TransactionType.PeerPushCredit) {
- throw Error("invalid transaction ID type");
- }
- peerPushCreditId = parsedTx.peerPushCreditId;
- } else {
- throw Error("no transaction ID (or deprecated peerPushCreditId) provided");
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+ if (parsedTx.tag !== TransactionType.PeerPushCredit) {
+ throw Error("invalid transaction ID type");
}
+ peerPushCreditId = parsedTx.peerPushCreditId;
- await ws.db
- .mktx((x) => [x.contractTerms, x.peerPushCredit])
- .runReadWrite(async (tx) => {
+ logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
return;
@@ -658,15 +927,18 @@ export async function confirmPeerPushCredit(
peerInc.status = PeerPushCreditStatus.PendingMerge;
}
await tx.peerPushCredit.put(peerInc);
- });
+ },
+ );
if (!peerInc) {
throw Error(
- `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`,
+ `can't accept unknown incoming p2p push payment (${req.transactionId})`,
);
}
- ws.workAvailable.trigger();
+ const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
@@ -678,200 +950,6 @@ export async function confirmPeerPushCredit(
};
}
-export async function suspendPeerPushCreditTransaction(
- ws: InternalWalletState,
- peerPushCreditId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushCredit])
- .runReadWrite(async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.SuspendedMerge;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- // FIXME: Suspend internal withdrawal transaction!
- newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function abortPeerPushCreditTransaction(
- ws: InternalWalletState,
- peerPushCreditId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushCredit])
- .runReadWrite(async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Done:
- break;
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-export async function failPeerPushCreditTransaction(
- ws: InternalWalletState,
- peerPushCreditId: string,
-) {
- // We don't have any "aborting" states!
- throw Error("can't run cancel-aborting on peer-push-credit transaction");
-}
-
-export async function resumePeerPushCreditTransaction(
- ws: InternalWalletState,
- peerPushCreditId: string,
-) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- stopLongpolling(ws, taskId);
- const transitionInfo = await ws.db
- .mktx((x) => [x.peerPushCredit])
- .runReadWrite(async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.PendingMergeKycRequired:
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingWithdrawing:
- case PeerPushCreditStatus.SuspendedMerge:
- newStatus = PeerPushCreditStatus.PendingMerge;
- break;
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPushCreditStatus.SuspendedWithdrawing:
- // FIXME: resume underlying "internal-withdrawal" transaction.
- newStatus = PeerPushCreditStatus.PendingWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- });
- ws.workAvailable.trigger();
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
export function computePeerPushCreditTransactionState(
pushCreditRecord: PeerPushPaymentIncomingRecord,
): TransactionState {
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
new file mode 100644
index 000000000..6452407ff
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -0,0 +1,1322 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ 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 {
+ Amounts,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
+ CoinRefreshRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
+ Logger,
+ NotificationType,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
+import {
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { createRefreshGroup, waitRefreshFinal } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-push-debit.ts");
+
+export class PeerPushDebitTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(pursePub);
+ if (debit) {
+ await tx.peerPushDebit.delete(pursePub);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.PendingReady:
+ newStatus = PeerPushDebitStatus.SuspendedReady;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ // Network request might already be in-flight!
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Expired:
+ case PeerPushDebitStatus.Failed:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.PendingReady;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ newStatus = PeerPushDebitStatus.PendingCreatePurse;
+ break;
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.startShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ // FIXME: What to do about the refresh group?
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function checkPeerPushDebit(
+ wex: WalletExecutionContext,
+ req: CheckPeerPushDebitRequest,
+): Promise<CheckPeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(req.amount);
+ logger.trace(
+ `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
+ );
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ logger.trace(`selected peer coins (len=${coins.length})`);
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+ logger.trace("computed total peer payment cost");
+ return {
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: req.amount,
+ maxExpirationDate: coinSelRes.result.maxExpirationDate,
+ };
+}
+
+async function handlePurseCreationConflict(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const errResp = await readTalerErrorResponse(resp);
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const sel = peerPushInitiation.coinSel;
+
+ checkDbInvariant(!!sel);
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ case "prospective":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ await wex.db.runReadWriteTx({ storeNames: ["peerPushDebit"] }, async (tx) => {
+ const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPushDebit.put(myPpi);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processPeerPushDebitCreateReserve(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const purseExpiration = peerPushInitiation.purseExpiration;
+ const hContractTerms = peerPushInitiation.contractTermsHash;
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ const transactionId = ctx.transactionId;
+
+ logger.trace(`processing ${transactionId} pending(create-reserve)`);
+
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
+ return tx.contractTerms.get(hContractTerms);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error(
+ `db invariant failed, contract terms for ${transactionId} missing`,
+ );
+ }
+
+ if (!peerPushInitiation.coinSel) {
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient funds (blocked on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ const ppi = await tx.peerPushDebit.get(pursePub);
+ if (!ppi) {
+ return false;
+ }
+ if (ppi.coinSel) {
+ return false;
+ }
+
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+
+ await tx.peerPushDebit.put(ppi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ }
+ return TaskRunResult.backoff();
+ }
+
+ const purseSigResp = await wex.cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: peerPushInitiation.mergePub,
+ minAge: 0,
+ purseAmount: peerPushInitiation.amount,
+ purseExpiration: timestampProtocolFromDb(purseExpiration),
+ pursePriv: peerPushInitiation.pursePriv,
+ });
+
+ const coins = await queryCoinInfosForSelection(
+ wex,
+ peerPushInitiation.coinSel,
+ );
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ mergePriv: peerPushInitiation.mergePriv,
+ pursePriv: peerPushInitiation.pursePriv,
+ pursePub: peerPushInitiation.pursePub,
+ contractPriv: peerPushInitiation.contractPriv,
+ contractPub: peerPushInitiation.contractPub,
+ nonce: peerPushInitiation.contractEncNonce,
+ };
+
+ const econtractResp = await wex.cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+ const batchCoins = coins.slice(i, i + batchSize);
+
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ pursePub: peerPushInitiation.pursePub,
+ coins: batchCoins,
+ });
+
+ if (i == 0) {
+ // First batch creates the purse!
+
+ logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
+
+ const createPurseUrl = new URL(
+ `purses/${peerPushInitiation.pursePub}/create`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const reqBody = {
+ amount: peerPushInitiation.amount,
+ merge_pub: peerPushInitiation.mergePub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: timestampProtocolFromDb(purseExpiration),
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`request body: ${j2s(reqBody)}`);
+ }
+
+ const httpResp = await wex.http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ } else {
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ }
+ }
+
+ // All batches done!
+
+ await transitionPeerPushDebitTransaction(wex, pursePub, {
+ stFrom: PeerPushDebitStatus.PendingCreatePurse,
+ stTo: PeerPushDebitStatus.PendingReady,
+ });
+
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingDeletePurse(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPushInitiation;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+
+ const sigResp = await wex.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(
+ `purses/${pursePub}`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ const resp = await wex.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!ppiRec.coinSel) {
+ return undefined;
+ }
+
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+interface SimpleTransition {
+ stFrom: PeerPushDebitStatus;
+ stTo: PeerPushDebitStatus;
+}
+
+// FIXME: This should be a transition on the peer push debit transaction context!
+async function transitionPeerPushDebitTransaction(
+ wex: WalletExecutionContext,
+ pursePub: string,
+ transitionSpec: SimpleTransition,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== transitionSpec.stFrom) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ ppiRec.status = transitionSpec.stTo;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function processPeerPushDebitAbortingRefreshDeleted(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ if (peerPushInitiation.abortRefreshGroupId) {
+ await waitRefreshFinal(wex, peerPushInitiation.abortRefreshGroupId);
+ }
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups", "peerPushDebit"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingRefreshExpired(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Expired;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Process the "pending(ready)" state of a peer-push-debit transaction.
+ */
+async function processPeerPushDebitReady(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ logger.trace("processing peer-push-debit pending(ready)");
+ const pursePub = peerPushInitiation.pursePub;
+ const transactionId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ const mergeUrl = new URL(
+ `purses/${pursePub}/merge`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ mergeUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling on purse status at ${mergeUrl.href}`);
+ const resp = await wex.http.fetch(mergeUrl.href, {
+ // timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ if (resp.status === HttpStatusCode.Ok) {
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+ const mergeTimestamp = purseStatus.merge_timestamp;
+ logger.info(`got purse status ${j2s(purseStatus)}`);
+ if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
+ return TaskRunResult.backoff();
+ } else {
+ await transitionPeerPushDebitTransaction(
+ wex,
+ peerPushInitiation.pursePub,
+ {
+ stFrom: PeerPushDebitStatus.PendingReady,
+ stTo: PeerPushDebitStatus.Done,
+ },
+ );
+ return TaskRunResult.progress();
+ }
+ } else if (resp.status === HttpStatusCode.Gone) {
+ logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (ppiRec.coinSel) {
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ }
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
+ return TaskRunResult.longpollReturnedPending();
+ }
+}
+
+export async function processPeerPushDebit(
+ wex: WalletExecutionContext,
+ pursePub: string,
+): Promise<TaskRunResult> {
+ const peerPushInitiation = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ return tx.peerPushDebit.get(pursePub);
+ },
+ );
+ if (!peerPushInitiation) {
+ throw Error("peer push payment not found");
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return processPeerPushDebitCreateReserve(wex, peerPushInitiation);
+ case PeerPushDebitStatus.PendingReady:
+ return processPeerPushDebitReady(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return processPeerPushDebitAbortingRefreshDeleted(
+ wex,
+ peerPushInitiation,
+ );
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return processPeerPushDebitAbortingRefreshExpired(
+ wex,
+ peerPushInitiation,
+ );
+ default: {
+ const txState = computePeerPushDebitTransactionState(peerPushInitiation);
+ logger.warn(
+ `not processing peer-push-debit transaction in state ${j2s(txState)}`,
+ );
+ }
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * Initiate sending a peer-to-peer push payment.
+ */
+export async function initiatePeerPushDebit(
+ wex: WalletExecutionContext,
+ req: InitiatePeerPushDebitRequest,
+): Promise<InitiatePeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(
+ req.partialContractTerms.amount,
+ );
+ const purseExpiration = req.partialContractTerms.purse_expiration;
+ const contractTerms = req.partialContractTerms;
+
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const sel = coinSelRes.result;
+
+ logger.info(`selected p2p coins (push):`);
+ logger.trace(`${j2s(coinSelRes)}`);
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ logger.info(`computed total peer payment cost`);
+
+ const pursePub = pursePair.pub;
+
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+
+ const transactionId = ctx.transactionId;
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ const ppi: PeerPushDebitRecord = {
+ amount: Amounts.stringify(instructedAmount),
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: sel.exchangeBaseUrl,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ purseExpiration: timestampProtocolToDb(purseExpiration),
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ contractEncNonce,
+ totalCost: Amounts.stringify(totalAmount),
+ };
+
+ if (coinSelRes.type === "success") {
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+ }
+
+ await tx.peerPushDebit.add(ppi);
+
+ await tx.contractTerms.put({
+ h: hContractTerms,
+ contractTermsRaw: contractTerms,
+ });
+
+ const newTxState = computePeerPushDebitTransactionState(ppi);
+ return {
+ oldTxState: { major: TransactionMajorState.None },
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ contractPriv: contractKeyPair.priv,
+ mergePriv: mergePair.priv,
+ pursePub: pursePair.pub,
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ };
+}
+
+export function computePeerPushDebitTransactionActions(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionAction[] {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Failed:
+ return [TransactionAction.Delete];
+ }
+}
+
+export function computePeerPushDebitTransactionState(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionState {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushDebitStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPushDebitStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts
deleted file mode 100644
index f8406033a..000000000
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ /dev/null
@@ -1,252 +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/>
- */
-
-/**
- * Type and schema definitions for pending tasks in the wallet.
- *
- * These are only used internally, and are not part of the stable public
- * interface to the wallet.
- */
-
-/**
- * Imports.
- */
-import { TalerErrorDetail, AbsoluteTime } from "@gnu-taler/taler-util";
-import { DbRetryInfo } from "./operations/common.js";
-
-export enum PendingTaskType {
- ExchangeUpdate = "exchange-update",
- ExchangeCheckRefresh = "exchange-check-refresh",
- Purchase = "purchase",
- Refresh = "refresh",
- Recoup = "recoup",
- RewardPickup = "reward-pickup",
- Withdraw = "withdraw",
- Deposit = "deposit",
- Backup = "backup",
- PeerPushDebit = "peer-push-debit",
- PeerPullCredit = "peer-pull-credit",
- PeerPushCredit = "peer-push-credit",
- PeerPullDebit = "peer-pull-debit",
-}
-
-/**
- * Information about a pending operation.
- */
-export type PendingTaskInfo = PendingTaskInfoCommon &
- (
- | PendingExchangeUpdateTask
- | PendingExchangeCheckRefreshTask
- | PendingPurchaseTask
- | PendingRefreshTask
- | PendingTipPickupTask
- | PendingWithdrawTask
- | PendingRecoupTask
- | PendingDepositTask
- | PendingBackupTask
- | PendingPeerPushInitiationTask
- | PendingPeerPullInitiationTask
- | PendingPeerPullDebitTask
- | PendingPeerPushCreditTask
- );
-
-export interface PendingBackupTask {
- type: PendingTaskType.Backup;
- backupProviderBaseUrl: string;
- lastError: TalerErrorDetail | undefined;
-}
-
-/**
- * The wallet is currently updating information about an exchange.
- */
-export interface PendingExchangeUpdateTask {
- type: PendingTaskType.ExchangeUpdate;
- exchangeBaseUrl: string;
- lastError: TalerErrorDetail | undefined;
-}
-
-/**
- * The wallet wants to send a peer push payment.
- */
-export interface PendingPeerPushInitiationTask {
- type: PendingTaskType.PeerPushDebit;
- pursePub: string;
-}
-
-/**
- * The wallet wants to send a peer pull payment.
- */
-export interface PendingPeerPullInitiationTask {
- type: PendingTaskType.PeerPullCredit;
- pursePub: string;
-}
-
-/**
- * The wallet wants to send a peer pull payment.
- */
-export interface PendingPeerPullDebitTask {
- type: PendingTaskType.PeerPullDebit;
- peerPullDebitId: string;
-}
-
-/**
- */
-export interface PendingPeerPushCreditTask {
- type: PendingTaskType.PeerPushCredit;
- peerPushCreditId: string;
-}
-
-/**
- * The wallet should check whether coins from this exchange
- * need to be auto-refreshed.
- */
-export interface PendingExchangeCheckRefreshTask {
- type: PendingTaskType.ExchangeCheckRefresh;
- exchangeBaseUrl: string;
-}
-
-export enum ReserveType {
- /**
- * Manually created.
- */
- Manual = "manual",
- /**
- * Withdrawn from a bank that has "tight" Taler integration
- */
- TalerBankWithdraw = "taler-bank-withdraw",
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingRefreshTask {
- type: PendingTaskType.Refresh;
- lastError?: TalerErrorDetail;
- refreshGroupId: string;
- finishedPerCoin: boolean[];
- retryInfo?: DbRetryInfo;
-}
-
-/**
- * The wallet is picking up a tip that the user has accepted.
- */
-export interface PendingTipPickupTask {
- type: PendingTaskType.RewardPickup;
- tipId: string;
- merchantBaseUrl: string;
- merchantTipId: string;
-}
-
-/**
- * A purchase needs to be processed (i.e. for download / payment / refund).
- */
-export interface PendingPurchaseTask {
- type: PendingTaskType.Purchase;
- proposalId: string;
- retryInfo?: DbRetryInfo;
- /**
- * Status of the payment as string, used only for debugging.
- */
- statusStr: string;
- lastError: TalerErrorDetail | undefined;
-}
-
-export interface PendingRecoupTask {
- type: PendingTaskType.Recoup;
- recoupGroupId: string;
- retryInfo?: DbRetryInfo;
- lastError: TalerErrorDetail | undefined;
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingWithdrawTask {
- type: PendingTaskType.Withdraw;
- lastError: TalerErrorDetail | undefined;
- retryInfo?: DbRetryInfo;
- withdrawalGroupId: string;
-}
-
-/**
- * Status of an ongoing deposit operation.
- */
-export interface PendingDepositTask {
- type: PendingTaskType.Deposit;
- lastError: TalerErrorDetail | undefined;
- retryInfo: DbRetryInfo | undefined;
- depositGroupId: string;
-}
-
-declare const __taskId: unique symbol;
-export type TaskId = string & { [__taskId]: true };
-
-/**
- * Fields that are present in every pending operation.
- */
-export interface PendingTaskInfoCommon {
- /**
- * Type of the pending operation.
- */
- type: PendingTaskType;
-
- /**
- * Unique identifier for the pending task.
- */
- id: TaskId;
-
- /**
- * Set to true if the operation indicates that something is really in progress,
- * as opposed to some regular scheduled operation that can be tried later.
- */
- givesLifeness: boolean;
-
- /**
- * Operation is active and waiting for a longpoll result.
- */
- isLongpolling: boolean;
-
- /**
- * Operation is waiting to be executed.
- */
- isDue: boolean;
-
- /**
- * Timestamp when the pending operation should be executed next.
- */
- timestampDue: AbsoluteTime;
-
- /**
- * Retry info. Currently used to stop the wallet after any operation
- * exceeds a number of retries.
- */
- retryInfo?: DbRetryInfo;
-
- /**
- * Internal operation status for debugging.
- */
- internalOperationStatus?: string;
-}
-
-/**
- * Response returned from the pending operations API.
- */
-export interface PendingOperationsResponse {
- /**
- * List of pending operations.
- */
- pendingOperations: PendingTaskInfo[];
-}
diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/query.ts
index 309c17a43..dc15bbdd1 100644
--- a/packages/taler-wallet-core/src/util/query.ts
+++ b/packages/taler-wallet-core/src/query.ts
@@ -15,27 +15,33 @@
*/
/**
- * Database query abstractions.
- * @module Query
+ * @fileoverview
+ * Query helpers for IndexedDB databases.
+ *
* @author Florian Dold
*/
/**
* Imports.
*/
-import { openPromise } from "./promiseUtils.js";
import {
- IDBRequest,
- IDBTransaction,
- IDBValidKey,
+ IDBCursor,
IDBDatabase,
IDBFactory,
- IDBVersionChangeEvent,
- IDBCursor,
IDBKeyPath,
IDBKeyRange,
+ IDBRequest,
+ IDBTransaction,
+ IDBTransactionMode,
+ IDBValidKey,
+ IDBVersionChangeEvent,
} from "@gnu-taler/idb-bridge";
-import { Codec, Logger, j2s } from "@gnu-taler/taler-util";
+import {
+ CancellationToken,
+ Codec,
+ Logger,
+ openPromise,
+} from "@gnu-taler/taler-util";
const logger = new Logger("query.ts");
@@ -250,9 +256,9 @@ export function openDatabase(
): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const req = idbFactory.open(databaseName, databaseVersion);
- req.onerror = (e) => {
- logger.error("database error", e);
- reject(new Error("database error"));
+ req.onerror = (event) => {
+ // @ts-expect-error
+ reject(new Error(`database opening error`, { cause: req.error }));
};
req.onsuccess = (e) => {
req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
@@ -268,11 +274,17 @@ export function openDatabase(
const db = req.result;
const newVersion = e.newVersion;
if (!newVersion) {
- throw Error("upgrade needed, but new version unknown");
+ // @ts-expect-error
+ throw Error("upgrade needed, but new version unknown", {
+ cause: req.error,
+ });
}
const transaction = req.transaction;
if (!transaction) {
- throw Error("no transaction handle available in upgrade handler");
+ // @ts-expect-error
+ throw Error("no transaction handle available in upgrade handler", {
+ cause: req.error,
+ });
}
logger.info(
`handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`,
@@ -344,6 +356,11 @@ interface IndexReadOnlyAccessor<RecordType> {
query?: IDBKeyRange | IDBValidKey,
count?: number,
): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
}
type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
@@ -357,6 +374,11 @@ interface IndexReadWriteAccessor<RecordType> {
query?: IDBKeyRange | IDBValidKey,
count?: number,
): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
}
type GetIndexReadWriteAccess<RecordType, IndexMap> = {
@@ -365,6 +387,10 @@ type GetIndexReadWriteAccess<RecordType, IndexMap> = {
export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
iter(query?: IDBValidKey): ResultStream<RecordType>;
indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
}
@@ -378,6 +404,10 @@ export interface InsertResponse {
export interface StoreReadWriteAccessor<RecordType, IndexMap> {
get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
iter(query?: IDBValidKey): ResultStream<RecordType>;
put(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
add(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
@@ -454,8 +484,8 @@ type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T &
KeyPathComponents}`
? T[PX]
: P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
- ? DerefKeyPath<T[P0], Rest>
- : unknown;
+ ? DerefKeyPath<T[P0], Rest>
+ : unknown;
/**
* Return a path if it is a valid dot-separate path to an object.
@@ -465,8 +495,8 @@ type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T &
KeyPathComponents}`
? PX
: P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
- ? `${P0}.${ValidateKeyPath<T[P0], Rest>}`
- : never;
+ ? `${P0}.${ValidateKeyPath<T[P0], Rest>}`
+ : never;
// function foo<T, P>(
// x: T,
@@ -475,85 +505,66 @@ type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T &
// foo({x: [0,1,2]}, "x.0");
-export type GetReadOnlyAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer StoreName,
- infer RecordType,
- infer IndexMap
- >
- ? StoreReadOnlyAccessor<RecordType, IndexMap>
- : unknown;
-};
-
export type StoreNames<StoreMap> = StoreMap extends {
[P in keyof StoreMap]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>;
}
? keyof StoreMap
: unknown;
-export type DbReadOnlyTransaction<
+export type DbReadWriteTransaction<
StoreMap,
- Stores extends StoreNames<StoreMap> & string,
+ StoresArr extends Array<StoreNames<StoreMap>>,
> = StoreMap extends {
- [P in Stores]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>;
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
}
? {
- [P in Stores]: StoreMap[P] extends StoreWithIndexes<
- infer StoreName,
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
infer RecordType,
infer IndexMap
>
- ? StoreReadOnlyAccessor<RecordType, IndexMap>
+ ? StoreReadWriteAccessor<RecordType, IndexMap>
: unknown;
}
- : unknown;
+ : never;
-export type DbReadWriteTransaction<
+export type DbReadOnlyTransaction<
StoreMap,
- Stores extends StoreNames<StoreMap> & string,
+ StoresArr extends Array<StoreNames<StoreMap>>,
> = StoreMap extends {
- [P in Stores]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>;
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
}
? {
- [P in Stores]: StoreMap[P] extends StoreWithIndexes<
- infer StoreName,
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
infer RecordType,
infer IndexMap
>
- ? StoreReadWriteAccessor<RecordType, IndexMap>
+ ? StoreReadOnlyAccessor<RecordType, IndexMap>
: unknown;
}
- : unknown;
-
-export type GetReadWriteAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer StoreName,
- infer RecordType,
- infer IndexMap
- >
- ? StoreReadWriteAccessor<RecordType, IndexMap>
- : unknown;
-};
-
-type ReadOnlyTransactionFunction<BoundStores, T> = (
- t: GetReadOnlyAccess<BoundStores>,
- rawTx: IDBTransaction,
-) => Promise<T>;
-
-type ReadWriteTransactionFunction<BoundStores, T> = (
- t: GetReadWriteAccess<BoundStores>,
- rawTx: IDBTransaction,
-) => Promise<T>;
+ : never;
-export interface TransactionContext<BoundStores> {
- runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>;
- runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
+/**
+ * Convert the type of an array to a union of the contents.
+ *
+ * Example:
+ * Input ["foo", "bar"]
+ * Output "foo" | "bar"
+ */
+export type UnionFromArray<Arr> = Arr extends {
+ [X in keyof Arr]: Arr[X] & string;
}
+ ? Arr[keyof Arr & number]
+ : unknown;
function runTx<Arg, Res>(
tx: IDBTransaction,
arg: Arg,
f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
+ triggerContext: InternalTriggerContext,
): Promise<Res> {
const stack = Error("Failed transaction was started here.");
return new Promise((resolve, reject) => {
@@ -574,6 +585,7 @@ function runTx<Arg, Res>(
logger.error(`${stack.stack}`);
reject(Error(msg));
}
+ triggerContext.handleAfterCommit();
resolve(funResult);
};
tx.onerror = () => {
@@ -618,6 +630,7 @@ function runTx<Arg, Res>(
function makeReadContext(
tx: IDBTransaction,
storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
): any {
const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
for (const storeAlias in storePick) {
@@ -630,10 +643,12 @@ function makeReadContext(
const indexName = indexDescriptor.name;
indexes[indexAlias] = {
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).get(key);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -641,21 +656,42 @@ function makeReadContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
.getAll(query, count);
return requestToPromise(req);
},
+ getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
};
}
ctx[storeAlias] = {
indexes,
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
},
@@ -667,6 +703,7 @@ function makeReadContext(
function makeWriteContext(
tx: IDBTransaction,
storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
): any {
const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
for (const storeAlias in storePick) {
@@ -679,10 +716,12 @@ function makeWriteContext(
const indexName = indexDescriptor.name;
indexes[indexAlias] = {
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).get(key);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -690,25 +729,48 @@ function makeWriteContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
.getAll(query, count);
return requestToPromise(req);
},
+ getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
};
}
ctx[storeAlias] = {
indexes,
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
},
async add(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).add(r, k);
const key = await requestToPromise(req);
return {
@@ -716,6 +778,8 @@ function makeWriteContext(
};
},
async put(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).put(r, k);
const key = await requestToPromise(req);
return {
@@ -723,6 +787,8 @@ function makeWriteContext(
};
},
delete(k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).delete(k);
return requestToPromise(req);
},
@@ -731,128 +797,208 @@ function makeWriteContext(
return ctx;
}
-type StoreNamesOf<X> = X extends { [x: number]: infer F }
- ? F extends { storeName: infer I }
- ? I
- : never
- : never;
+export interface DbAccess<StoreMap> {
+ idbHandle(): IDBDatabase;
+
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+}
+
+export interface AfterCommitInfo {
+ mode: IDBTransactionMode;
+ scope: Set<string>;
+ accessedStores: Set<string>;
+ modifiedStores: Set<string>;
+}
+
+export interface TriggerSpec {
+ /**
+ * Trigger run after every successful commit, run outside of the transaction.
+ */
+ afterCommit?: (info: AfterCommitInfo) => void;
+
+ // onRead(store, value)
+ // initState<State> () => State
+ // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>;
+}
+
+class InternalTriggerContext {
+ storesScope: Set<string>;
+ storesAccessed: Set<string> = new Set();
+ storesModified: Set<string> = new Set();
+
+ constructor(
+ private triggerSpec: TriggerSpec,
+ private mode: IDBTransactionMode,
+ scope: string[],
+ ) {
+ this.storesScope = new Set(scope);
+ }
+
+ handleAfterCommit() {
+ if (this.triggerSpec.afterCommit) {
+ this.triggerSpec.afterCommit({
+ mode: this.mode,
+ accessedStores: this.storesAccessed,
+ modifiedStores: this.storesModified,
+ scope: this.storesScope,
+ });
+ }
+ }
+}
/**
* Type-safe access to a database with a particular store map.
*
* A store map is the metadata that describes the store.
*/
-export class DbAccess<StoreMap> {
- constructor(private db: IDBDatabase, private stores: StoreMap) {}
+export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private db: IDBDatabase,
+ private stores: StoreMap,
+ private triggers: TriggerSpec = {},
+ private cancellationToken: CancellationToken,
+ ) {}
idbHandle(): IDBDatabase {
return this.db;
}
- /**
- * Run a transaction with all object stores.
- */
- mktxAll(): TransactionContext<StoreMap> {
- const storeNames: string[] = [];
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
- for (let i = 0; i < this.db.objectStoreNames.length; i++) {
- const sn = this.db.objectStoreNames[i];
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
- if (!swi) {
- throw Error(`store metadata not available (${sn})`);
- }
- storeNames.push(sn);
- accessibleStores[sn] = swi;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
}
+ const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ return runTx(tx, writeContext, txf, triggerContext);
+ }
- const storeMapKeys = Object.keys(this.stores as any);
- for (const storeMapKey of storeMapKeys) {
- const swi = (this.stores as any)[storeMapKey] as StoreWithIndexes<
- any,
- any,
- any
- >;
- if (!accessibleStores[swi.storeName]) {
- const version = this.db.version;
- throw Error(
- `store '${swi.storeName}' required by schema but not in database (minver=${version})`,
- );
- }
+ async runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
}
-
- const runReadOnly = <T>(
- txf: ReadOnlyTransactionFunction<StoreMap, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readonly");
- const readContext = makeReadContext(tx, accessibleStores);
- return runTx(tx, readContext, txf);
- };
-
- const runReadWrite = <T>(
- txf: ReadWriteTransactionFunction<StoreMap, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readwrite");
- const writeContext = makeWriteContext(tx, accessibleStores);
- return runTx(tx, writeContext, txf);
- };
-
- return {
- runReadOnly,
- runReadWrite,
- };
+ const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
+ return res;
}
- /**
- * Run a transaction with selected object stores.
- *
- * The {@link namePicker} must be a function that selects a list of object
- * stores from all available object stores.
- */
- mktx<
- StoreNames extends keyof StoreMap,
- Stores extends StoreMap[StoreNames],
- StoreList extends Stores[],
- BoundStores extends {
- [X in StoreNamesOf<StoreList>]: StoreList[number] & { storeName: X };
+ async runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
},
- >(namePicker: (x: StoreMap) => StoreList): TransactionContext<BoundStores> {
- const storeNames: string[] = [];
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
-
- const storePick = namePicker(this.stores) as any;
- if (typeof storePick !== "object" || storePick === null) {
- throw Error();
- }
- for (const swiPicked of storePick) {
- const swi = swiPicked as StoreWithIndexes<any, any, any>;
- if (swi.mark !== storeWithIndexesSymbol) {
- throw Error("invalid store descriptor returned from selector function");
- }
- storeNames.push(swi.storeName);
+ const strStoreNames: string[] = [];
+ for (const sn of opts.storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
accessibleStores[swi.storeName] = swi;
}
+ const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
+ return res;
+ }
- const runReadOnly = <T>(
- txf: ReadOnlyTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readonly");
- const readContext = makeReadContext(tx, accessibleStores);
- return runTx(tx, readContext, txf);
- };
-
- const runReadWrite = <T>(
- txf: ReadWriteTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readwrite");
- const writeContext = makeWriteContext(tx, accessibleStores);
- return runTx(tx, writeContext, txf);
- };
-
- return {
- runReadOnly,
- runReadWrite,
- };
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of opts.storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const readContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = runTx(tx, readContext, txf, triggerContext);
+ return res;
}
}
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
index 782e98d1c..6a09f9a0e 100644
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ b/packages/taler-wallet-core/src/recoup.ts
@@ -30,7 +30,10 @@ import {
Logger,
RefreshReason,
TalerPreciseTimestamp,
+ TransactionIdStr,
+ TransactionType,
URL,
+ checkDbInvariant,
codecForRecoupConfirmation,
codecForReserveStatus,
encodeCrock,
@@ -39,37 +42,40 @@ import {
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
CoinRecord,
CoinSourceType,
RecoupGroupRecord,
+ RecoupOperationStatus,
RefreshCoinSource,
- WalletStoresV1,
+ WalletDbReadWriteTransaction,
WithdrawCoinSource,
WithdrawalGroupStatus,
WithdrawalRecordType,
timestampPreciseToDb,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import { TaskRunResult } from "./common.js";
+} from "./db.js";
import { createRefreshGroup } from "./refresh.js";
+import { constructTransactionIdentifier } from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
-const logger = new Logger("operations/recoup.ts");
+export const logger = new Logger("operations/recoup.ts");
/**
* Store a recoup group record in the database after marking
* a coin in the group as finished.
*/
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
+export async function putGroupAsFinished(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
recoupGroup: RecoupGroupRecord,
coinIdx: number,
): Promise<void> {
@@ -84,7 +90,7 @@ async function putGroupAsFinished(
}
async function recoupRewardCoin(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
@@ -92,14 +98,9 @@ async function recoupRewardCoin(
// We can't really recoup a coin we got via tipping.
// Thus we just put the coin to sleep.
// FIXME: somehow report this to the user
- await ws.db
- .mktx((stores) => [
- stores.recoupGroups,
- stores.denominations,
- stores.refreshGroups,
- stores.coins,
- ])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["recoupGroups", "denominations", "refreshGroups", "coins"] },
+ async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
@@ -107,90 +108,23 @@ async function recoupRewardCoin(
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-}
-
-async function recoupWithdrawCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: WithdrawCoinSource,
-): Promise<void> {
- const reservePub = cs.reservePub;
- const denomInfo = await ws.db
- .mktx((x) => [x.denominations])
- .runReadOnly(async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- return denomInfo;
- });
- if (!denomInfo) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest({
- blindingKey: coin.blindingKey,
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPub: denomInfo.denomPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- });
- 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);
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
);
-
- logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db
- .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.coins.get(coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- await tx.coins.put(updatedCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
}
async function recoupRefreshCoin(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
cs: RefreshCoinSource,
): Promise<void> {
- const d = await ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
+ const d = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
@@ -199,13 +133,14 @@ async function recoupRefreshCoin(
return;
}
return { denomInfo };
- });
+ },
+ );
if (!d) {
// FIXME: We should at least emit some pending operation / warning for this?
return;
}
- const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
+ const recoupRequest = await wex.cryptoApi.createRecoupRefreshRequest({
blindingKey: coin.blindingKey,
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
@@ -219,7 +154,10 @@ async function recoupRefreshCoin(
);
logger.trace(`making recoup request for ${coin.coinPub}`);
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
const recoupConfirmation = await readSuccessResponseJsonOrThrow(
resp,
codecForRecoupConfirmation(),
@@ -229,9 +167,9 @@ async function recoupRefreshCoin(
throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
}
- await ws.db
- .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
+ async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
@@ -249,14 +187,14 @@ async function recoupRefreshCoin(
logger.warn("refresh old coin for recoup not found");
return;
}
- const oldCoinDenom = await ws.getDenomInfo(
- ws,
+ const oldCoinDenom = await getDenomInfo(
+ wex,
tx,
oldCoin.exchangeBaseUrl,
oldCoin.denomPubHash,
);
- const revokedCoinDenom = await ws.getDenomInfo(
- ws,
+ const revokedCoinDenom = await getDenomInfo(
+ wex,
tx,
revokedCoin.exchangeBaseUrl,
revokedCoin.denomPubHash,
@@ -281,19 +219,93 @@ async function recoupRefreshCoin(
}
await tx.coins.put(revokedCoin);
await tx.coins.put(oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function recoupWithdrawCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: WithdrawCoinSource,
+): Promise<void> {
+ const reservePub = cs.reservePub;
+ const denomInfo = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ return denomInfo;
+ },
+ );
+ if (!denomInfo) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await wex.cryptoApi.createRecoupRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ logger.trace(`requesting recoup via ${reqUrl.href}`);
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
+
+ if (recoupConfirmation.reserve_pub !== reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on recoup`);
+ }
+
+ // FIXME: verify that our expectations about the amount match
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const updatedCoin = await tx.coins.get(coin.coinPub);
+ if (!updatedCoin) {
+ return;
+ }
+ updatedCoin.status = CoinStatus.Dormant;
+ await tx.coins.put(updatedCoin);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
}
export async function processRecoupGroup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
recoupGroupId: string,
): Promise<TaskRunResult> {
- let recoupGroup = await ws.db
- .mktx((x) => [x.recoupGroups])
- .runReadOnly(async (tx) => {
+ let recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
return tx.recoupGroups.get(recoupGroupId);
- });
+ },
+ );
if (!recoupGroup) {
return TaskRunResult.finished();
}
@@ -303,7 +315,7 @@ export async function processRecoupGroup(
}
const ps = recoupGroup.coinPubs.map(async (x, i) => {
try {
- await processRecoup(ws, recoupGroupId, i);
+ await processRecoupForCoin(wex, recoupGroupId, i);
} catch (e) {
logger.warn(`processRecoup failed: ${e}`);
throw e;
@@ -311,11 +323,12 @@ export async function processRecoupGroup(
});
await Promise.all(ps);
- recoupGroup = await ws.db
- .mktx((x) => [x.recoupGroups])
- .runReadOnly(async (tx) => {
+ recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
return tx.recoupGroups.get(recoupGroupId);
- });
+ },
+ );
if (!recoupGroup) {
return TaskRunResult.finished();
}
@@ -332,9 +345,9 @@ export async function processRecoupGroup(
const reservePrivMap: Record<string, string> = {};
for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
const coinPub = recoupGroup.coinPubs[i];
- await ws.db
- .mktx((x) => [x.coins, x.reserves])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "reserves"] },
+ async (tx) => {
const coin = await tx.coins.get(coinPub);
if (!coin) {
throw Error(`Coin ${coinPub} not found, can't request recoup`);
@@ -349,7 +362,8 @@ export async function processRecoupGroup(
reserveSet.add(coin.coinSource.reservePub);
reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
}
- });
+ },
+ );
}
for (const reservePub of reserveSet) {
@@ -359,13 +373,13 @@ export async function processRecoupGroup(
);
logger.info(`querying reserve status for recoup via ${reserveUrl}`);
- const resp = await ws.http.fetch(reserveUrl.href);
+ const resp = await wex.http.fetch(reserveUrl.href);
const result = await readSuccessResponseJsonOrThrow(
resp,
codecForReserveStatus(),
);
- await internalCreateWithdrawalGroup(ws, {
+ await internalCreateWithdrawalGroup(wex, {
amount: Amounts.parseOrThrow(result.balance),
exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
@@ -379,42 +393,82 @@ export async function processRecoupGroup(
});
}
- await ws.db
- .mktx((x) => [
- x.recoupGroups,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "recoupGroups",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ ],
+ },
+ async (tx) => {
const rg2 = await tx.recoupGroups.get(recoupGroupId);
if (!rg2) {
return;
}
rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg2.operationStatus = RecoupOperationStatus.Finished;
if (rg2.scheduleRefreshCoins.length > 0) {
- const refreshGroupId = await createRefreshGroup(
- ws,
+ await createRefreshGroup(
+ wex,
tx,
Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
rg2.scheduleRefreshCoins,
RefreshReason.Recoup,
+ constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId: rg2.recoupGroupId,
+ }),
);
}
await tx.recoupGroups.put(rg2);
- });
+ },
+ );
return TaskRunResult.finished();
}
+export class RecoupTransactionContext implements TransactionContext {
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ deleteTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ private recoupGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId,
+ });
+ }
+}
+
export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
exchangeBaseUrl: string,
coinPubs: string[],
): Promise<string> {
@@ -428,13 +482,14 @@ export async function createRecoupGroup(
timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
recoupFinishedPerCoin: coinPubs.map(() => false),
scheduleRefreshCoins: [],
+ operationStatus: RecoupOperationStatus.Pending,
};
for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
const coinPub = coinPubs[coinIdx];
const coin = await tx.coins.get(coinPub);
if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
continue;
}
await tx.coins.put(coin);
@@ -442,20 +497,24 @@ export async function createRecoupGroup(
await tx.recoupGroups.put(recoupGroup);
+ const ctx = new RecoupTransactionContext(wex, recoupGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
return recoupGroupId;
}
/**
* Run the recoup protocol for a single coin in a recoup group.
*/
-async function processRecoup(
- ws: InternalWalletState,
+async function processRecoupForCoin(
+ wex: WalletExecutionContext,
recoupGroupId: string,
coinIdx: number,
): Promise<void> {
- const coin = await ws.db
- .mktx((x) => [x.recoupGroups, x.coins])
- .runReadOnly(async (tx) => {
+ const coin = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "recoupGroups"] },
+ async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
return;
@@ -474,7 +533,8 @@ async function processRecoup(
throw Error(`Coin ${coinPub} not found, can't request recoup`);
}
return coin;
- });
+ },
+ );
if (!coin) {
return;
@@ -484,11 +544,11 @@ async function processRecoup(
switch (cs.type) {
case CoinSourceType.Reward:
- return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin);
+ return recoupRewardCoin(wex, recoupGroupId, coinIdx, coin);
case CoinSourceType.Refresh:
- return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs);
case CoinSourceType.Withdraw:
- return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs);
default:
throw Error("unknown coin source type");
}
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
new file mode 100644
index 000000000..7800967e6
--- /dev/null
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -0,0 +1,1883 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the refresh transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AgeCommitment,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ amountToPretty,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForCoinHistoryResponse,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenominationInfo,
+ DenomKeyType,
+ Duration,
+ encodeCrock,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ ExchangeRefreshRevealRequest,
+ fnutil,
+ ForceRefreshRequest,
+ getErrorDetailFromException,
+ getRandomBytes,
+ HashCodeString,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ NotificationType,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ constructTaskIdentifier,
+ makeCoinsVisible,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+} from "./common.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import {
+ DerivedRefreshSession,
+ RefreshNewDenomInfo,
+} from "./crypto/cryptoTypes.js";
+import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinAvailabilityRecord,
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ RefreshCoinStatus,
+ RefreshGroupPerExchangeInfo,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefreshSessionRecord,
+ timestampPreciseToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ TransitionInfo,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
+
+const logger = new Logger("refresh.ts");
+
+/**
+ * Update the materialized refresh transaction based
+ * on the refresh group record.
+ */
+async function updateRefreshTransaction(
+ ctx: RefreshTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {}
+
+export class RefreshTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public refreshGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: RefreshGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<RefreshGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "refreshGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: stores },
+ async (tx) => {
+ const wgRec = await tx.refreshGroups.get(this.refreshGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeRefreshTransactionState(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.refreshGroups.put(res.rec);
+ await updateRefreshTransaction(this, tx);
+ const newTxState = computeRefreshTransactionState(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.refreshGroups.delete(this.refreshGroupId);
+ await updateRefreshTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteRefreshGroup + ":" + this.refreshGroupId,
+ });
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Suspended:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending: {
+ rec.operationStatus = RefreshOperationStatus.Suspended;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async abortTransaction(): Promise<void> {
+ // Refresh transactions only support fail, not abort.
+ throw new Error("refresh transactions cannot be aborted");
+ }
+
+ async resumeTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Pending:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Pending;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async failTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Failed;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+}
+
+export async function getTotalRefreshCost(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): Promise<AmountJson> {
+ const cacheKey = `denom=${refreshedDenom.exchangeBaseUrl}/${
+ refreshedDenom.denomPubHash
+ };left=${Amounts.stringify(amountLeft)}`;
+ const cacheRes = wex.ws.refreshCostCache.get(cacheKey);
+ if (cacheRes) {
+ return cacheRes;
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ refreshedDenom.exchangeBaseUrl,
+ Amounts.currencyOf(amountLeft),
+ );
+ const res = getTotalRefreshCostInternal(
+ allDenoms,
+ refreshedDenom,
+ amountLeft,
+ );
+ wex.ws.refreshCostCache.put(cacheKey, res);
+ return res;
+}
+
+/**
+ * Get the amount that we lose when refreshing a coin of the given denomination
+ * with a certain amount left.
+ *
+ * If the amount left is zero, then the refresh cost
+ * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
+ * the right denominations), then the cost is the full amount left.
+ *
+ * Considers refresh fees, withdrawal fees after refresh and amounts too small
+ * to refresh.
+ */
+export function getTotalRefreshCostInternal(
+ denoms: DenominationRecord[],
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): AmountJson {
+ const withdrawAmount = Amounts.sub(
+ amountLeft,
+ refreshedDenom.feeRefresh,
+ ).amount;
+ const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
+ const withdrawDenoms = selectWithdrawalDenominations(
+ withdrawAmount,
+ denoms,
+ false,
+ );
+ const resultingAmount = Amounts.add(
+ Amounts.zeroOfCurrency(withdrawAmount.currency),
+ ...withdrawDenoms.selectedDenoms.map(
+ (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
+ ),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
+ totalCost,
+ )}`,
+ );
+ return totalCost;
+}
+
+async function getCoinAvailabilityForDenom(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ denom: DenominationInfo,
+ ageRestriction: number,
+): Promise<CoinAvailabilityRecord> {
+ checkDbInvariant(!!denom);
+ let car = await tx.coinAvailability.get([
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: Amounts.currencyOf(denom.value),
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ return car;
+}
+
+/**
+ * Create a refresh session for one particular coin inside a refresh group.
+ */
+async function initRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["refreshSessions", "coinAvailability", "coins", "denominations"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ coinIndex: number,
+): Promise<void> {
+ const refreshGroupId = refreshGroup.refreshGroupId;
+ logger.trace(
+ `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
+ );
+ const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
+ const oldCoin = await tx.coins.get(oldCoinPub);
+ if (!oldCoin) {
+ throw Error("Can't refresh, coin not found");
+ }
+
+ const exchangeBaseUrl = oldCoin.exchangeBaseUrl;
+
+ const sessionSecretSeed = encodeCrock(getRandomBytes(64));
+
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ const currency = refreshGroup.currency;
+
+ const availableDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const availableAmount = Amounts.sub(
+ refreshGroup.inputPerCoin[coinIndex],
+ oldDenom.feeRefresh,
+ ).amount;
+
+ const newCoinDenoms = selectWithdrawalDenominations(
+ availableAmount,
+ availableDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ if (newCoinDenoms.selectedDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ refreshGroup.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ return;
+ }
+
+ for (let i = 0; i < newCoinDenoms.selectedDenoms.length; i++) {
+ const dph = newCoinDenoms.selectedDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldDenom.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ car.pendingRefreshOutputCount =
+ (car.pendingRefreshOutputCount ?? 0) +
+ newCoinDenoms.selectedDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+
+ const newSession: RefreshSessionRecord = {
+ coinIndex,
+ refreshGroupId,
+ norevealIndex: undefined,
+ sessionSecretSeed: sessionSecretSeed,
+ newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
+ count: x.count,
+ denomPubHash: x.denomPubHash,
+ })),
+ amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
+ };
+ await tx.refreshSessions.put(newSession);
+}
+
+/**
+ * Uninitialize a refresh session.
+ *
+ * Adjust the coin availability of involved coins.
+ */
+async function destroyRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coinAvailability", "coins"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ refreshSession: RefreshSessionRecord,
+): Promise<void> {
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const oldCoin = await tx.coins.get(
+ refreshGroup.oldCoinPubs[refreshSession.coinIndex],
+ );
+ if (!oldCoin) {
+ continue;
+ }
+ const dph = refreshSession.newDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldCoin.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ checkDbInvariant(car.pendingRefreshOutputCount != null);
+ car.pendingRefreshOutputCount =
+ car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+}
+
+function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
+ return Duration.fromSpec({
+ seconds: 5,
+ });
+}
+
+/**
+ * Run the melt step of a refresh session.
+ *
+ * If the melt step succeeds or fails permanently,
+ * the status in the refresh group is updated.
+ *
+ * When a transient error occurs, an exception is thrown.
+ */
+async function refreshMelt(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ newCoinDenoms,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ let maybeAch: HashCodeString | undefined;
+ if (oldCoin.ageCommitmentProof) {
+ maybeAch = AgeRestriction.hashCommitment(
+ oldCoin.ageCommitmentProof.commitment,
+ );
+ }
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: derived.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: derived.hash,
+ value_with_fee: Amounts.stringify(derived.meltValueWithFee),
+ age_commitment_hash: maybeAch,
+ };
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.NotFound: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltNotFound(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltGone(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Conflict: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltConflict(
+ ctx,
+ coinIndex,
+ errDetail,
+ derived,
+ oldCoin,
+ );
+ return;
+ }
+ case HttpStatusCode.Ok:
+ break;
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups", "refreshSessions"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ await tx.refreshSessions.put(rs);
+ },
+ );
+}
+
+async function handleRefreshMeltGone(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails);
+
+ // FIXME: Validate signature.
+
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+async function handleRefreshMeltConflict(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+ derived: DerivedRefreshSession,
+ oldCoin: CoinRecord,
+): Promise<void> {
+ // Just log for better diagnostics here, error status
+ // will be handled later.
+ logger.error(
+ `melt request for ${Amounts.stringify(
+ derived.meltValueWithFee,
+ )} failed in refresh group ${ctx.refreshGroupId} due to conflict`,
+ );
+
+ const historySig = await ctx.wex.cryptoApi.signCoinHistoryRequest({
+ coinPriv: oldCoin.coinPriv,
+ coinPub: oldCoin.coinPub,
+ startOffset: 0,
+ });
+
+ const historyUrl = new URL(
+ `coins/${oldCoin.coinPub}/history`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const historyResp = await ctx.wex.http.fetch(historyUrl.href, {
+ method: "GET",
+ headers: {
+ "Taler-Coin-History-Signature": historySig.sig,
+ },
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ historyResp,
+ codecForCoinHistoryResponse(),
+ );
+ logger.info(`coin history: ${j2s(historyJson)}`);
+
+ // FIXME: If response seems wrong, report to auditor (in the future!);
+
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ if (Amounts.isZero(historyJson.balance)) {
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ } else {
+ // Try again with new denoms!
+ rg.inputPerCoin[coinIndex] = historyJson.balance;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshSessions.delete([ctx.refreshGroupId, coinIndex]);
+ await initRefreshSession(ctx.wex, tx, rg, coinIndex);
+ }
+ },
+ );
+}
+
+async function handleRefreshMeltNotFound(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // FIXME: Validate the exchange's error response
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function assembleRefreshRevealRequest(args: {
+ cryptoApi: TalerCryptoInterface;
+ derived: DerivedRefreshSession;
+ norevealIndex: number;
+ oldCoinPub: CoinPublicKeyString;
+ oldCoinPriv: string;
+ newDenoms: {
+ denomPubHash: string;
+ count: number;
+ }[];
+ oldAgeCommitment?: AgeCommitment;
+}): Promise<ExchangeRefreshRevealRequest> {
+ const {
+ derived,
+ norevealIndex,
+ cryptoApi,
+ oldCoinPriv,
+ oldCoinPub,
+ newDenoms,
+ } = args;
+ const privs = Array.from(derived.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ const planchets = derived.planchetsForGammas[norevealIndex];
+ if (!planchets) {
+ throw Error("refresh index error");
+ }
+
+ const newDenomsFlat: string[] = [];
+ const linkSigs: string[] = [];
+
+ for (let i = 0; i < newDenoms.length; i++) {
+ const dsel = newDenoms[i];
+ for (let j = 0; j < dsel.count; j++) {
+ const newCoinIndex = linkSigs.length;
+ const linkSig = await cryptoApi.signCoinLink({
+ coinEv: planchets[newCoinIndex].coinEv,
+ newDenomHash: dsel.denomPubHash,
+ oldCoinPriv: oldCoinPriv,
+ oldCoinPub: oldCoinPub,
+ transferPub: derived.transferPubs[norevealIndex],
+ });
+ linkSigs.push(linkSig.sig);
+ newDenomsFlat.push(dsel.denomPubHash);
+ }
+ }
+
+ const req: ExchangeRefreshRevealRequest = {
+ coin_evs: planchets.map((x) => x.coinEv),
+ new_denoms_h: newDenomsFlat,
+ transfer_privs: privs,
+ transfer_pub: derived.transferPubs[norevealIndex],
+ link_sigs: linkSigs,
+ old_age_commitment: args.oldAgeCommitment?.publicKeys,
+ };
+ return req;
+}
+
+async function refreshReveal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
+ );
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ const norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex === undefined) {
+ throw Error("can't reveal without melting first");
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ newCoinDenoms,
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `refreshes/${derived.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const req = await assembleRefreshRevealRequest({
+ cryptoApi: wex.cryptoApi,
+ derived,
+ newDenoms: newCoinDenoms,
+ norevealIndex: norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
+ });
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ body: req,
+ method: "POST",
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshRevealError(ctx, coinIndex, errDetail);
+ return;
+ }
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeRevealResponse(),
+ );
+
+ const coins: CoinRecord[] = [];
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const ncd = newCoinDenoms[i];
+ for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
+ const newCoinIndex = coins.length;
+ const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
+ if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("cipher unsupported");
+ }
+ const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
+ const denomSig = await wex.cryptoApi.unblindDenominationSignature({
+ planchet: {
+ blindingKey: pc.blindingKey,
+ denomPub: ncd.denomPub,
+ },
+ evSig,
+ });
+ const coin: CoinRecord = {
+ blindingKey: pc.blindingKey,
+ coinPriv: pc.coinPriv,
+ coinPub: pc.coinPub,
+ denomPubHash: ncd.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: oldCoin.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Refresh,
+ refreshGroupId,
+ oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
+ },
+ sourceTransactionId: transactionId,
+ coinEvHash: pc.coinEvHash,
+ maxAge: pc.maxAge,
+ ageCommitmentProof: pc.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ coins.push(coin);
+ }
+ }
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ logger.warn("no refresh session found");
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ for (const coin of coins) {
+ const existingCoin = await tx.coins.get(coin.coinPub);
+ if (existingCoin) {
+ continue;
+ }
+ await tx.coins.add(coin);
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denomInfo);
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denomInfo,
+ coin.maxAge,
+ );
+ checkDbInvariant(
+ car.pendingRefreshOutputCount != null &&
+ car.pendingRefreshOutputCount > 0,
+ );
+ car.pendingRefreshOutputCount--;
+ car.freshCoinCount++;
+ await tx.coinAvailability.put(car);
+ }
+ await tx.refreshGroups.put(rg);
+ },
+ );
+ logger.trace("refresh finished (end of reveal)");
+}
+
+async function handleRefreshRevealError(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function processRefreshGroup(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace(`processing refresh group ${refreshGroupId}`);
+
+ const refreshGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups"] },
+ async (tx) => tx.refreshGroups.get(refreshGroupId),
+ );
+ if (!refreshGroup) {
+ return TaskRunResult.finished();
+ }
+ if (refreshGroup.timestampFinished) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ wex.ws.config.testing.devModeActive &&
+ wex.ws.devExperimentState.blockRefreshes
+ ) {
+ throw Error("refresh blocked");
+ }
+
+ // Process refresh sessions of the group in parallel.
+ logger.trace(
+ `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
+ );
+ let errors: TalerErrorDetail[] = [];
+ let inShutdown = false;
+ const ps = refreshGroup.oldCoinPubs.map((x, i) =>
+ processRefreshSession(wex, refreshGroupId, i).catch((x) => {
+ if (x instanceof CryptoApiStoppedError) {
+ inShutdown = true;
+ logger.info(
+ "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
+ );
+ return;
+ }
+ if (x instanceof TalerError) {
+ logger.warn("process refresh session got exception (TalerError)");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ logger.warn(`error detail: ${j2s(x.errorDetail)}`);
+ } else {
+ logger.warn("process refresh session got exception");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ }
+ errors.push(getErrorDetailFromException(x));
+ }),
+ );
+ await Promise.all(ps);
+ if (inShutdown) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // We've processed all refresh session and can now update the
+ // status of the whole refresh group.
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability", "refreshGroups"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ break;
+ default:
+ return undefined;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ const allFinal = fnutil.all(
+ rg.statusPerCoin,
+ (x) =>
+ x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
+ );
+ const anyFailed = fnutil.any(
+ rg.statusPerCoin,
+ (x) => x === RefreshCoinStatus.Failed,
+ );
+ if (allFinal) {
+ if (anyFailed) {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Failed;
+ } else {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Finished;
+ }
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ await tx.refreshGroups.put(rg);
+ const newTxState = computeRefreshTransactionState(rg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+
+ if (transitionInfo) {
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ if (errors.length > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
+ {
+ numErrors: errors.length,
+ errors: errors.slice(0, 5),
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processRefreshSession(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
+ );
+ let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "refreshSessions"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ return {
+ refreshGroup: rg,
+ refreshSession: rs,
+ };
+ },
+ );
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
+ return;
+ }
+ if (!refreshSession) {
+ // No refresh session for that coin.
+ return;
+ }
+ if (refreshSession.norevealIndex === undefined) {
+ await refreshMelt(wex, refreshGroupId, coinIndex);
+ }
+ await refreshReveal(wex, refreshGroupId, coinIndex);
+}
+
+export interface RefreshOutputInfo {
+ outputPerCoin: AmountJson[];
+ perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
+}
+
+export async function calculateRefreshOutput(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+): Promise<RefreshOutputInfo> {
+ const estimatedOutputPerCoin: AmountJson[] = [];
+
+ const denomsPerExchange: Record<string, DenominationRecord[]> = {};
+
+ const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
+
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ const refreshAmount = ocp.amount;
+ const cost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denom,
+ Amounts.parseOrThrow(refreshAmount),
+ );
+ const output = Amounts.sub(refreshAmount, cost).amount;
+ let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
+ if (!exchInfo) {
+ infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
+ outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
+ };
+ }
+ exchInfo.outputEffective = Amounts.stringify(
+ Amounts.add(exchInfo.outputEffective, output).amount,
+ );
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ perExchangeInfo: infoPerExchange,
+ };
+}
+
+async function applyRefreshToOldCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshGroupId: string,
+): Promise<void> {
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ switch (coin.status) {
+ case CoinStatus.Dormant:
+ break;
+ case CoinStatus.Fresh: {
+ coin.status = CoinStatus.Dormant;
+ const coinAv = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAv);
+ checkDbInvariant(coinAv.freshCoinCount > 0);
+ coinAv.freshCoinCount--;
+ await tx.coinAvailability.put(coinAv);
+ break;
+ }
+ case CoinStatus.FreshSuspended: {
+ // For suspended coins, we don't have to adjust coin
+ // availability, as they are not counted as available.
+ coin.status = CoinStatus.Dormant;
+ break;
+ }
+ case CoinStatus.DenomLoss:
+ break;
+ default:
+ assertUnreachable(coin.status);
+ }
+ if (!coin.spendAllocation) {
+ coin.spendAllocation = {
+ amount: Amounts.stringify(ocp.amount),
+ // id: `txn:refresh:${refreshGroupId}`,
+ id: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ }),
+ };
+ }
+ await tx.coins.put(coin);
+ }
+}
+
+export interface CreateRefreshGroupResult {
+ refreshGroupId: string;
+ notifications: WalletNotification[];
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ *
+ * Refreshes the remaining amount on the coin, effectively capturing the remaining
+ * value in the refresh group.
+ *
+ * The caller must also ensure that the coins that should be refreshed exist
+ * in the current database transaction.
+ */
+export async function createRefreshGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ ]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshReason: RefreshReason,
+ originatingTransactionId: string | undefined,
+): Promise<CreateRefreshGroupResult> {
+ // FIXME: Check that involved exchanges are reasonably up-to-date.
+ // Otherwise, error out.
+
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const outInfo = await calculateRefreshOutput(wex, tx, currency, oldCoinPubs);
+
+ const estimatedOutputPerCoin = outInfo.outputPerCoin;
+
+ await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId);
+
+ const refreshGroup: RefreshGroupRecord = {
+ operationStatus: RefreshOperationStatus.Pending,
+ currency,
+ timestampFinished: undefined,
+ statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
+ oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
+ originatingTransactionId,
+ reason: refreshReason,
+ refreshGroupId,
+ inputPerCoin: oldCoinPubs.map((x) => x.amount),
+ expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
+ Amounts.stringify(x),
+ ),
+ infoPerExchange: outInfo.perExchangeInfo,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+
+ if (oldCoinPubs.length == 0) {
+ logger.warn("created refresh group with zero coins");
+ refreshGroup.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ refreshGroup.operationStatus = RefreshOperationStatus.Finished;
+ }
+
+ for (let i = 0; i < oldCoinPubs.length; i++) {
+ await initRefreshSession(wex, tx, refreshGroup, i);
+ }
+
+ await tx.refreshGroups.put(refreshGroup);
+
+ const newTxState = computeRefreshTransactionState(refreshGroup);
+
+ logger.trace(`created refresh group ${refreshGroupId}`);
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // Shepherd the task.
+ // If the current transaction fails to commit the refresh
+ // group to the DB, the shepherd will give up.
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ refreshGroupId,
+ notifications: [
+ {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: ctx.transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ },
+ ],
+ };
+}
+
+export function computeRefreshTransactionState(
+ rg: RefreshGroupRecord,
+): TransactionState {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefreshOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefreshOperationStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefreshOperationStatus.Suspended:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ }
+}
+
+export function computeRefreshTransactionActions(
+ rg: RefreshGroupRecord,
+): TransactionAction[] {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Pending:
+ return [
+ TransactionAction.Retry,
+ TransactionAction.Suspend,
+ TransactionAction.Fail,
+ ];
+ case RefreshOperationStatus.Suspended:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
+
+export function getRefreshesForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<string[]> {
+ return wex.db.runReadOnlyTx({ storeNames: ["refreshGroups"] }, async (tx) => {
+ const groups =
+ await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
+ transactionId,
+ );
+ return groups.map((x) =>
+ constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: x.refreshGroupId,
+ }),
+ );
+ });
+}
+
+export interface ForceRefreshResult {
+ refreshGroupId: string;
+}
+
+export async function forceRefresh(
+ wex: WalletExecutionContext,
+ req: ForceRefreshRequest,
+): Promise<ForceRefreshResult> {
+ if (req.refreshCoinSpecs.length == 0) {
+ throw Error("refusing to create empty refresh group");
+ }
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "coinAvailability",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ let coinPubs: CoinRefreshRequest[] = [];
+ for (const c of req.refreshCoinSpecs) {
+ const coin = await tx.coins.get(c.coinPub);
+ if (!coin) {
+ throw Error(`coin (pubkey ${c}) not found`);
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ coinPubs.push({
+ coinPub: c.coinPub,
+ amount: c.amount ?? denom.value,
+ });
+ }
+ return await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(coinPubs[0].amount),
+ coinPubs,
+ RefreshReason.Manual,
+ undefined,
+ );
+ },
+ );
+
+ for (const notif of res.notifications) {
+ wex.ws.notify(notif);
+ }
+
+ return {
+ refreshGroupId: res.refreshGroupId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitRefreshFinal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const refreshNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ refreshNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ refreshNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitRefreshFinal(ctx, refreshNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitRefreshFinal(
+ ctx: RefreshTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ rg: await tx.refreshGroups.get(ctx.refreshGroupId),
+ };
+ },
+ );
+ const { rg } = res;
+ if (!rg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Finished:
+ // Transaction is final
+ return;
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts
index 164f7cfe9..d7623baab 100644
--- a/packages/taler-wallet-core/src/remote.ts
+++ b/packages/taler-wallet-core/src/remote.ts
@@ -17,13 +17,13 @@
import {
CoreApiRequestEnvelope,
CoreApiResponse,
- j2s,
Logger,
+ OpenedPromise,
+ openPromise,
TalerError,
WalletNotification,
} from "@gnu-taler/taler-util";
import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc";
-import { OpenedPromise, openPromise } from "./index.js";
import { WalletCoreApiClient } from "./wallet-api-types.js";
const logger = new Logger("remote.ts");
@@ -44,6 +44,7 @@ export interface RemoteWallet {
}
export interface RemoteWalletConnectArgs {
+ name?: string;
socketFilename: string;
notificationHandler?: (n: WalletNotification) => void;
}
@@ -85,14 +86,13 @@ export async function createRemoteWallet(
return {
result: ctx,
onDisconnect() {
- logger.info("remote wallet disconnected");
+ logger.info(`${args.name}: remote wallet disconnected`);
},
onMessage(m) {
// FIXME: use a codec for parsing the response envelope!
- logger.info(`got message from remote wallet: ${j2s(m)}`);
if (typeof m !== "object" || m == null) {
- logger.warn("message from wallet not understood (wrong type)");
+ logger.warn(`${args.name}: message not understood (wrong type)`);
return;
}
const type = (m as any).type;
@@ -100,13 +100,15 @@ export async function createRemoteWallet(
const id = (m as any).id;
if (typeof id !== "string") {
logger.warn(
- "message from wallet not understood (no id in response)",
+ `${args.name}: message not understood (no id in response)`,
);
return;
}
const h = requestMap.get(id);
if (!h) {
- logger.warn(`no handler registered for response id ${id}`);
+ logger.warn(
+ `${args.name}: no handler registered for response id ${id}`,
+ );
return;
}
h.promiseCapability.resolve(m as any);
@@ -115,7 +117,7 @@ export async function createRemoteWallet(
args.notificationHandler((m as any).payload);
}
} else {
- logger.warn("message from wallet not understood");
+ logger.warn(`${args.name}: message not understood`);
}
},
};
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
new file mode 100644
index 000000000..3b160d97f
--- /dev/null
+++ b/packages/taler-wallet-core/src/shepherd.ts
@@ -0,0 +1,1128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AsyncCondition,
+ CancellationToken,
+ Duration,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ TalerErrorDetail,
+ TaskThrottler,
+ TransactionIdStr,
+ TransactionState,
+ TransactionType,
+ WalletNotification,
+ assertUnreachable,
+ getErrorDetailFromException,
+ j2s,
+ safeStringifyException,
+} from "@gnu-taler/taler-util";
+import { processBackupForProvider } from "./backup/index.js";
+import {
+ DbRetryInfo,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ constructTaskIdentifier,
+ getExchangeState,
+ parseTaskIdentifier,
+} from "./common.js";
+import {
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ OperationRetryRecord,
+ WalletDbAllStoresReadOnlyTransaction,
+ WalletDbReadOnlyTransaction,
+ timestampAbsoluteFromDb,
+} from "./db.js";
+import {
+ computeDepositTransactionStatus,
+ processDepositGroup,
+} from "./deposits.js";
+import {
+ computeDenomLossTransactionStatus,
+ updateExchangeFromUrlHandler,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ processPurchase,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionState,
+ processPeerPullCredit,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionState,
+ processPeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionState,
+ processPeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionState,
+ processPeerPushDebit,
+} from "./pay-peer-push-debit.js";
+import { processRecoupGroup } from "./recoup.js";
+import {
+ computeRefreshTransactionState,
+ processRefreshGroup,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ InternalWalletState,
+ WalletExecutionContext,
+ getNormalWalletExecutionContext,
+ getObservedWalletExecutionContext,
+} from "./wallet.js";
+import {
+ computeWithdrawalTransactionStatus,
+ processWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("shepherd.ts");
+
+/**
+ * Info about one task being shepherded.
+ */
+interface ShepherdInfo {
+ cts: CancellationToken.Source;
+}
+
+/**
+ * Check if a task is alive, i.e. whether it prevents
+ * the main task loop from exiting.
+ */
+function taskGivesLiveness(taskId: string): boolean {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.Backup:
+ case PendingTaskType.ExchangeUpdate:
+ return false;
+ case PendingTaskType.Deposit:
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.Recoup:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return true;
+ default:
+ assertUnreachable(parsedTaskId);
+ }
+}
+
+export interface TaskScheduler {
+ ensureRunning(): Promise<void>;
+ startShepherdTask(taskId: TaskIdStr): void;
+ stopShepherdTask(taskId: TaskIdStr): void;
+ resetTaskRetries(taskId: TaskIdStr): Promise<void>;
+ reload(): Promise<void>;
+ getActiveTasks(): TaskIdStr[];
+ isIdle(): boolean;
+ shutdown(): Promise<void>;
+}
+
+export class TaskSchedulerImpl implements TaskScheduler {
+ private sheps: Map<TaskIdStr, ShepherdInfo> = new Map();
+
+ private iterCond = new AsyncCondition();
+
+ private throttler = new TaskThrottler();
+
+ isRunning: boolean = false;
+
+ constructor(private ws: InternalWalletState) {}
+
+ private async loadTasksFromDb(): Promise<void> {
+ const activeTasks = await getActiveTaskIds(this.ws);
+
+ logger.info(`active tasks from DB: ${j2s(activeTasks)}`);
+
+ for (const tid of activeTasks.taskIds) {
+ this.startShepherdTask(tid);
+ }
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return [...this.sheps.keys()];
+ }
+
+ async shutdown(): Promise<void> {
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`Stopping task shepherd.`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ }
+
+ async ensureRunning(): Promise<void> {
+ if (this.isRunning) {
+ return;
+ }
+ this.isRunning = true;
+ try {
+ await this.loadTasksFromDb();
+ } catch (e) {
+ this.isRunning = false;
+ throw e;
+ }
+ this.run()
+ .catch((e) => {
+ logger.error("error running task loop");
+ logger.error(`err: ${e}`);
+ })
+ .then(() => {
+ logger.trace("done running task loop");
+ this.isRunning = false;
+ });
+ }
+
+ isIdle(): boolean {
+ let alive = false;
+ const taskIds = [...this.sheps.keys()];
+ for (const taskId of taskIds) {
+ if (taskGivesLiveness(taskId)) {
+ alive = true;
+ break;
+ }
+ }
+ // We're idle if no task is alive anymore.
+ return !alive;
+ }
+
+ private async run(): Promise<void> {
+ logger.trace("Running task loop.");
+ logger.trace(`sheps: ${this.sheps.size}`);
+ while (true) {
+ if (this.ws.stopped) {
+ logger.trace("Breaking out of task loop (wallet stopped).");
+ break;
+ }
+
+ if (this.isIdle()) {
+ this.ws.notify({
+ type: NotificationType.Idle,
+ });
+ }
+
+ await this.iterCond.wait();
+ }
+ logger.trace("Done with task loop.");
+ }
+
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.ensureRunning().catch((e) => {
+ logger.error(`error running scheduler: ${safeStringifyException(e)}`);
+ });
+ // Run in the background, no await!
+ this.internalStartShepherdTask(taskId);
+ }
+
+ /**
+ * Stop and re-load all existing tasks.
+ *
+ * Mostly useful to interrupt all waits when time-travelling.
+ */
+ async reload(): Promise<void> {
+ await this.ensureRunning();
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`reloading sheperd with ${tasksIds.length} tasks`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ for (const taskId of tasksIds) {
+ this.startShepherdTask(taskId);
+ }
+ }
+
+ private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> {
+ logger.trace(`Starting to shepherd task ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Already have a shepherd for ${taskId}`);
+ return;
+ }
+ logger.trace(`Creating new shepherd for ${taskId}`);
+ const newShep: ShepherdInfo = {
+ cts: CancellationToken.create(),
+ };
+ this.sheps.set(taskId, newShep);
+ try {
+ await this.internalShepherdTask(taskId, newShep);
+ } finally {
+ logger.trace(`Done shepherding ${taskId}`);
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ stopShepherdTask(taskId: TaskIdStr): void {
+ logger.trace(`Stopping shepherding of ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Cancelling old shepherd for ${taskId}`);
+ oldShep.cts.cancel();
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ restartShepherdTask(taskId: TaskIdStr): void {
+ this.stopShepherdTask(taskId);
+ this.startShepherdTask(taskId);
+ }
+
+ async resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ const maybeNotification = await this.ws.db.runAllStoresReadWriteTx(
+ {},
+ async (tx) => {
+ await tx.operationRetries.delete(taskId);
+ return taskToRetryNotification(this.ws, tx, taskId, undefined);
+ },
+ );
+ this.stopShepherdTask(taskId);
+ if (maybeNotification) {
+ this.ws.notify(maybeNotification);
+ }
+ this.startShepherdTask(taskId);
+ }
+
+ private async wait(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ delay: Duration,
+ ): Promise<void> {
+ try {
+ await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay));
+ } catch (e) {
+ logger.info(`waiting for ${taskId} interrupted`);
+ }
+ }
+
+ private async internalShepherdTask(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ ): Promise<void> {
+ while (true) {
+ if (this.ws.stopped) {
+ logger.trace(`Shepherd for ${taskId} stopping as wallet is stopped`);
+ return;
+ }
+ if (info.cts.token.isCancelled) {
+ logger.trace(`Shepherd for ${taskId} got cancelled`);
+ return;
+ }
+ const isThrottled = this.throttler.applyThrottle(taskId);
+ if (isThrottled) {
+ logger.warn(
+ `task ${taskId} throttled, this is very likely a bug in wallet-core, please report`,
+ );
+ logger.warn("waiting for 60 seconds");
+ await this.ws.timerGroup.resolveAfter(
+ Duration.fromSpec({ seconds: 60 }),
+ );
+ }
+ const wex = getWalletExecutionContextForTask(
+ this.ws,
+ taskId,
+ info.cts.token,
+ );
+ const startTime = AbsoluteTime.now();
+ logger.trace(`Shepherd for ${taskId} will call handler`);
+ let res: TaskRunResult;
+ try {
+ res = await callOperationHandlerForTaskId(wex, taskId);
+ } catch (e) {
+ res = {
+ type: TaskRunResultType.Error,
+ errorDetail: getErrorDetailFromException(e),
+ };
+ }
+ if (info.cts.token.isCancelled) {
+ logger.trace("task cancelled, not processing result");
+ return;
+ }
+ if (this.ws.stopped) {
+ logger.trace("wallet stopped, not processing result");
+ return;
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.ShepherdTaskResult,
+ resultType: res.type,
+ });
+ switch (res.type) {
+ case TaskRunResultType.Error: {
+ logger.trace(`Shepherd for ${taskId} got error result.`);
+ const retryRecord = await storePendingTaskError(
+ this.ws,
+ taskId,
+ res.errorDetail,
+ );
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Backoff: {
+ logger.trace(`Shepherd for ${taskId} got backoff result.`);
+ const retryRecord = await storePendingTaskPending(this.ws, taskId);
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Progress: {
+ logger.trace(
+ `Shepherd for ${taskId} got progress result, re-running immediately.`,
+ );
+ await storeTaskProgress(this.ws, taskId);
+ break;
+ }
+ case TaskRunResultType.ScheduleLater: {
+ logger.trace(`Shepherd for ${taskId} got schedule-later result.`);
+ await storeTaskProgress(this.ws, taskId);
+ const delay = AbsoluteTime.remaining(res.runAt);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Finished:
+ logger.trace(`Shepherd for ${taskId} got finished result.`);
+ await storePendingTaskFinished(this.ws, taskId);
+ return;
+ case TaskRunResultType.LongpollReturnedPending: {
+ await storeTaskProgress(this.ws, taskId);
+ // Make sure that we are waiting a bit if long-polling returned too early.
+ const endTime = AbsoluteTime.now();
+ const taskDuration = AbsoluteTime.difference(endTime, startTime);
+ if (
+ Duration.cmp(taskDuration, Duration.fromSpec({ seconds: 20 })) < 0
+ ) {
+ logger.info(
+ `long-poller for ${taskId} returned unexpectedly early (${taskDuration.d_ms} ms), waiting 10 seconds`,
+ );
+ await this.wait(taskId, info, Duration.fromSpec({ seconds: 10 }));
+ } else {
+ logger.info(`task ${taskId} will long-poll again`);
+ }
+ break;
+ }
+ default:
+ assertUnreachable(res);
+ }
+ }
+ }
+}
+
+async function storePendingTaskError(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+ e: TalerErrorDetail,
+): Promise<OperationRetryRecord> {
+ logger.info(`storing pending task error for ${pendingTaskId}`);
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ lastError: e,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ retryRecord.lastError = e;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ return {
+ notification: await taskToRetryNotification(ws, tx, pendingTaskId, e),
+ retryRecord,
+ };
+ });
+ if (res?.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+/**
+ * Task made progress, clear error.
+ */
+async function storeTaskProgress(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
+}
+
+async function storePendingTaskPending(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<OperationRetryRecord> {
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ let hadError = false;
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ if (retryRecord.lastError) {
+ hadError = true;
+ }
+ delete retryRecord.lastError;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ let notification: WalletNotification | undefined = undefined;
+ if (hadError) {
+ notification = await taskToRetryNotification(
+ ws,
+ tx,
+ pendingTaskId,
+ undefined,
+ );
+ }
+ return {
+ notification,
+ retryRecord,
+ };
+ });
+ if (res.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+async function storePendingTaskFinished(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
+}
+
+function getWalletExecutionContextForTask(
+ ws: InternalWalletState,
+ taskId: TaskIdStr,
+ cancellationToken: CancellationToken,
+): WalletExecutionContext {
+ let oc: ObservabilityContext;
+ let wex: WalletExecutionContext;
+
+ if (ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ if (ws.config.testing.emitObservabilityEvents) {
+ ws.notify({
+ type: NotificationType.TaskObservabilityEvent,
+ taskId,
+ event: evt,
+ });
+ }
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cancellationToken, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cancellationToken, oc);
+ }
+ return wex;
+}
+
+async function callOperationHandlerForTaskId(
+ wex: WalletExecutionContext,
+ taskId: TaskIdStr,
+): Promise<TaskRunResult> {
+ const pending = parseTaskIdentifier(taskId);
+ switch (pending.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl);
+ case PendingTaskType.Refresh:
+ return await processRefreshGroup(wex, pending.refreshGroupId);
+ case PendingTaskType.Withdraw:
+ return await processWithdrawalGroup(wex, pending.withdrawalGroupId);
+ case PendingTaskType.Purchase:
+ return await processPurchase(wex, pending.proposalId);
+ case PendingTaskType.Recoup:
+ return await processRecoupGroup(wex, pending.recoupGroupId);
+ case PendingTaskType.Deposit:
+ return await processDepositGroup(wex, pending.depositGroupId);
+ case PendingTaskType.Backup:
+ return await processBackupForProvider(wex, pending.backupProviderBaseUrl);
+ case PendingTaskType.PeerPushDebit:
+ return await processPeerPushDebit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullCredit:
+ return await processPeerPullCredit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullDebit:
+ return await processPeerPullDebit(wex, pending.peerPullDebitId);
+ case PendingTaskType.PeerPushCredit:
+ return await processPeerPushCredit(wex, pending.peerPushCreditId);
+ case PendingTaskType.RewardPickup:
+ throw Error("not supported anymore");
+ default:
+ return assertUnreachable(pending);
+ }
+ throw Error(`not reached ${pending.tag}`);
+}
+
+/**
+ * Generate an appropriate error transition notification
+ * for applicable tasks.
+ *
+ * Namely, transition notifications are generated for:
+ * - exchange update errors
+ * - transactions
+ */
+async function taskToRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Deposit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.Backup:
+ case PendingTaskType.Recoup:
+ return undefined;
+ }
+}
+
+async function getTransactionState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "depositGroups",
+ "withdrawalGroups",
+ "purchases",
+ "refundGroups",
+ "peerPullCredit",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "rewards",
+ "refreshGroups",
+ "denomLossEvents",
+ ]
+ >,
+ transactionId: string,
+): Promise<TransactionState | undefined> {
+ const parsedTxId = parseTransactionIdentifier(transactionId);
+ if (!parsedTxId) {
+ throw Error("invalid tx identifier");
+ }
+ switch (parsedTxId.tag) {
+ case TransactionType.Deposit: {
+ const rec = await tx.depositGroups.get(parsedTxId.depositGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDepositTransactionStatus(rec);
+ }
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeWithdrawalTransactionStatus(rec);
+ }
+ case TransactionType.Payment: {
+ const rec = await tx.purchases.get(parsedTxId.proposalId);
+ if (!rec) {
+ return;
+ }
+ return computePayMerchantTransactionState(rec);
+ }
+ case TransactionType.Refund: {
+ const rec = await tx.refundGroups.get(parsedTxId.refundGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefundTransactionState(rec);
+ }
+ case TransactionType.PeerPullCredit: {
+ const rec = await tx.peerPullCredit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPullDebit: {
+ const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullDebitTransactionState(rec);
+ }
+ case TransactionType.PeerPushCredit: {
+ const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPushDebit: {
+ const rec = await tx.peerPushDebit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushDebitTransactionState(rec);
+ }
+ case TransactionType.Refresh: {
+ const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefreshTransactionState(rec);
+ }
+ case TransactionType.Recoup:
+ throw Error("not yet supported");
+ case TransactionType.DenomLoss: {
+ const rec = await tx.denomLossEvents.get(parsedTxId.denomLossEventId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDenomLossTransactionStatus(rec);
+ }
+ default:
+ assertUnreachable(parsedTxId);
+ }
+}
+
+async function makeTransactionRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const txId = convertTaskToTransactionId(pendingTaskId);
+ if (!txId) {
+ return undefined;
+ }
+ const txState = await getTransactionState(ws, tx, txId);
+ if (!txState) {
+ return undefined;
+ }
+ const notif: WalletNotification = {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: txId,
+ oldTxState: txState,
+ newTxState: txState,
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+async function makeExchangeRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ logger.info("making exchange retry notification");
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+ if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) {
+ throw Error("invalid task identifier");
+ }
+ const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl);
+
+ if (!rec) {
+ logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`);
+ return undefined;
+ }
+
+ const notif: WalletNotification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: parsedTaskId.exchangeBaseUrl,
+ oldExchangeState: getExchangeState(rec),
+ newExchangeState: getExchangeState(rec),
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+export function listTaskForTransactionId(transactionId: string): TaskIdStr[] {
+ const tid = parseTransactionIdentifier(transactionId);
+ if (!tid) {
+ throw Error("invalid task ID");
+ }
+ switch (tid.tag) {
+ case TransactionType.Deposit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: tid.depositGroupId,
+ }),
+ ];
+ case TransactionType.InternalWithdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.Payment:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: tid.proposalId,
+ }),
+ ];
+ case TransactionType.PeerPullCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.PeerPullDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: tid.peerPullDebitId,
+ }),
+ ];
+ case TransactionType.PeerPushCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.peerPushCreditId,
+ }),
+ ];
+ case TransactionType.PeerPushDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.Recoup:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: tid.recoupGroupId,
+ }),
+ ];
+ case TransactionType.Refresh:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: tid.refreshGroupId,
+ }),
+ ];
+ case TransactionType.Refund:
+ return [];
+ case TransactionType.Withdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.DenomLoss:
+ return [];
+ default:
+ assertUnreachable(tid);
+ }
+}
+
+/**
+ * Convert the task ID for a task that processes a transaction int
+ * the ID for the transaction.
+ */
+export function convertTaskToTransactionId(
+ taskId: string,
+): TransactionIdStr | undefined {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.PeerPullCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.PeerPullDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: parsedTaskId.peerPullDebitId,
+ });
+ // FIXME: This doesn't distinguish internal-withdrawal.
+ // Maybe we should have a different task type for that as well?
+ // Or maybe transaction IDs should be valid task identifiers?
+ case PendingTaskType.Withdraw:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: parsedTaskId.withdrawalGroupId,
+ });
+ case PendingTaskType.PeerPushCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: parsedTaskId.peerPushCreditId,
+ });
+ case PendingTaskType.Deposit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: parsedTaskId.depositGroupId,
+ });
+ case PendingTaskType.Refresh:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: parsedTaskId.refreshGroupId,
+ });
+ case PendingTaskType.PeerPushDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.Purchase:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: parsedTaskId.proposalId,
+ });
+ default:
+ return undefined;
+ }
+}
+
+export interface ActiveTaskIdsResult {
+ taskIds: TaskIdStr[];
+}
+
+export async function getActiveTaskIds(
+ ws: InternalWalletState,
+): Promise<ActiveTaskIdsResult> {
+ const res: ActiveTaskIdsResult = {
+ taskIds: [],
+ };
+ await ws.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "refreshGroups",
+ "withdrawalGroups",
+ "purchases",
+ "depositGroups",
+ "recoupGroups",
+ "peerPullCredit",
+ "peerPushDebit",
+ "peerPullDebit",
+ "peerPushCredit",
+ ],
+ },
+ async (tx) => {
+ const active = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ // Withdrawals
+
+ {
+ const activeRecs =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: rec.withdrawalGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Deposits
+
+ {
+ const activeRecs =
+ await tx.depositGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: rec.depositGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Refreshes
+
+ {
+ const activeRecs =
+ await tx.refreshGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: rec.refreshGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Purchases
+
+ {
+ const activeRecs = await tx.purchases.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: rec.proposalId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-debit
+
+ {
+ const activeRecs =
+ await tx.peerPushDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-credit
+
+ {
+ const activeRecs =
+ await tx.peerPushCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: rec.peerPushCreditId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-debit
+
+ {
+ const activeRecs =
+ await tx.peerPullDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: rec.peerPullDebitId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-credit
+
+ {
+ const activeRecs =
+ await tx.peerPullCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // recoup
+
+ {
+ const activeRecs =
+ await tx.recoupGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: rec.recoupGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // exchange update
+
+ {
+ const exchanges = await tx.exchanges.getAll();
+ for (const rec of exchanges) {
+ const taskIdUpdate = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: rec.baseUrl,
+ });
+ res.taskIds.push(taskIdUpdate);
+ }
+ }
+
+ // FIXME: Recoup!
+ },
+ );
+
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/testing.ts
index d75fb54a7..899c4a8b2 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -25,8 +25,10 @@
*/
import {
AbsoluteTime,
+ addPaytoQueryParams,
Amounts,
AmountString,
+ checkLogicInvariant,
CheckPaymentResponse,
codecForAny,
codecForCheckPaymentResponse,
@@ -37,10 +39,9 @@ import {
j2s,
Logger,
NotificationType,
+ parsePaytoUri,
PreparePayResultType,
- stringifyTalerUri,
TalerCorebankApiClient,
- TalerUriAction,
TestPayArgs,
TestPayResult,
TransactionMajorState,
@@ -54,10 +55,9 @@ import {
HttpRequestLibrary,
readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
-import { OpenedPromise, openPromise } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkLogicInvariant } from "../util/invariants.js";
import { getBalances } from "./balance.js";
+import { genericWaitForState } from "./common.js";
+import { createDepositGroup } from "./deposits.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
confirmPay,
@@ -74,8 +74,9 @@ import {
preparePeerPushCredit,
} from "./pay-peer-push-credit.js";
import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
-import { getPendingOperations } from "./pending.js";
+import { getRefreshesForTransaction } from "./refresh.js";
import { getTransactionById, getTransactions } from "./transactions.js";
+import type { WalletExecutionContext } from "./wallet.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
const logger = new Logger("operations/testing.ts");
@@ -85,10 +86,22 @@ interface MerchantBackendInfo {
authToken?: string;
}
+export interface WithdrawTestBalanceResult {
+ /**
+ * Transaction ID of the newly created withdrawal transaction.
+ */
+ transactionId: string;
+
+ /**
+ * Account of the user registered for the withdrawal.
+ */
+ accountPaytoUri: string;
+}
+
export async function withdrawTestBalance(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: WithdrawTestBalanceRequest,
-): Promise<void> {
+): Promise<WithdrawTestBalanceResult> {
const amount = req.amount;
const exchangeBaseUrl = req.exchangeBaseUrl;
const corebankApiBaseUrl = req.corebankApiBaseUrl;
@@ -109,7 +122,7 @@ export async function withdrawTestBalance(
amount,
);
- await acceptWithdrawalFromUri(ws, {
+ const acceptResp = await acceptWithdrawalFromUri(wex, {
talerWithdrawUri: wresp.taler_withdraw_uri,
selectedExchange: exchangeBaseUrl,
forcedDenomSel: req.forcedDenomSel,
@@ -118,6 +131,11 @@ export async function withdrawTestBalance(
await corebankClient.confirmWithdrawalOperation(bankUser.username, {
withdrawalOperationId: wresp.withdrawal_id,
});
+
+ return {
+ transactionId: acceptResp.transactionId,
+ accountPaytoUri: bankUser.accountPaytoUri,
+ };
}
/**
@@ -151,7 +169,9 @@ async function refund(
reason,
refund: refundAmount,
};
- const resp = await http.postJson(reqUrl.href, refundReq, {
+ const resp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: refundReq,
headers: getMerchantAuthHeader(merchantBackend),
});
const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
@@ -183,7 +203,9 @@ async function createOrder(
wire_transfer_deadline: { t_s: t },
},
};
- const resp = await http.postJson(reqUrl, orderReq, {
+ const resp = await http.fetch(reqUrl, {
+ method: "POST",
+ body: orderReq,
headers: getMerchantAuthHeader(merchantBackend),
});
const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
@@ -210,14 +232,19 @@ async function checkPayment(
return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
}
+interface MakePaymentResult {
+ orderId: string;
+ paymentTransactionId: string;
+}
+
async function makePayment(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
merchant: MerchantBackendInfo,
amount: string,
summary: string,
-): Promise<{ orderId: string }> {
+): Promise<MakePaymentResult> {
const orderResp = await createOrder(
- ws.http,
+ wex.http,
merchant,
amount,
summary,
@@ -226,7 +253,7 @@ async function makePayment(
logger.trace("created order with orderId", orderResp.orderId);
- let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
+ let paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
logger.trace("payment status", paymentStatus);
@@ -235,7 +262,7 @@ async function makePayment(
throw Error("no taler://pay/ URI in payment response");
}
- const preparePayResult = await preparePayForUri(ws, talerPayUri);
+ const preparePayResult = await preparePayForUri(wex, talerPayUri);
logger.trace("prepare pay result", preparePayResult);
@@ -244,14 +271,14 @@ async function makePayment(
}
const confirmPayResult = await confirmPay(
- ws,
- preparePayResult.proposalId,
+ wex,
+ preparePayResult.transactionId,
undefined,
);
logger.trace("confirmPayResult", confirmPayResult);
- paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
+ paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
logger.trace("payment status after wallet payment:", paymentStatus);
@@ -261,11 +288,12 @@ async function makePayment(
return {
orderId: orderResp.orderId,
+ paymentTransactionId: preparePayResult.transactionId,
};
}
export async function runIntegrationTest(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
args: IntegrationTestArgs,
): Promise<void> {
logger.info("running test with arguments", args);
@@ -274,15 +302,15 @@ export async function runIntegrationTest(
const currency = parsedSpendAmount.currency;
logger.info("withdrawing test balance");
- await withdrawTestBalance(ws, {
+ const withdrawRes1 = await withdrawTestBalance(wex, {
amount: args.amountToWithdraw,
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
- await waitUntilTransactionsFinal(ws);
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]);
logger.info("done withdrawing test balance");
- const balance = await getBalances(ws);
+ const balance = await getBalances(wex);
logger.trace(JSON.stringify(balance, null, 2));
@@ -291,10 +319,17 @@ export async function runIntegrationTest(
authToken: args.merchantAuthToken,
};
- await makePayment(ws, myMerchant, args.amountToSpend, "hello world");
+ const makePaymentRes = await makePayment(
+ wex,
+ myMerchant,
+ args.amountToSpend,
+ "hello world",
+ );
- // Wait until the refresh is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
logger.trace("withdrawing test balance for refund");
const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
@@ -302,24 +337,23 @@ export async function runIntegrationTest(
const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
- await withdrawTestBalance(ws, {
+ const withdrawRes2 = await withdrawTestBalance(wex, {
amount: Amounts.stringify(withdrawAmountTwo),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
- // Wait until the withdraw is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]);
const { orderId: refundOrderId } = await makePayment(
- ws,
+ wex,
myMerchant,
Amounts.stringify(spendAmountTwo),
"order that will be refunded",
);
const refundUri = await refund(
- ws.http,
+ wex.http,
myMerchant,
refundOrderId,
"test refund",
@@ -328,17 +362,20 @@ export async function runIntegrationTest(
logger.trace("refund URI", refundUri);
- await startRefundQueryForUri(ws, refundUri);
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
logger.trace("integration test: applied refund");
// Wait until the refund is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
logger.trace("integration test: making payment after refund");
- await makePayment(
- ws,
+ const paymentResp2 = await makePayment(
+ wex,
myMerchant,
Amounts.stringify(spendAmountThree),
"payment after refund",
@@ -346,7 +383,13 @@ export async function runIntegrationTest(
logger.trace("integration test: make payment done");
- await waitUntilTransactionsFinal(ws);
+ await waitUntilGivenTransactionsFinal(wex, [
+ paymentResp2.paymentTransactionId,
+ ]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId),
+ );
logger.trace("integration test: all done!");
}
@@ -354,185 +397,181 @@ export async function runIntegrationTest(
/**
* Wait until all transactions are in a final state.
*/
-export async function waitUntilTransactionsFinal(
- ws: InternalWalletState,
+export async function waitUntilAllTransactionsFinal(
+ wex: WalletExecutionContext,
): Promise<void> {
logger.info("waiting until all transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
+ await wex.taskScheduler.ensureRunning();
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
- break;
+ return false;
default:
- p.resolve();
+ return true;
}
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
}
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
+ return true;
+ },
+ });
logger.info("done waiting until all transactions are in a final state");
}
+export async function waitTasksDone(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ await genericWaitForState(wex, {
+ async checkState() {
+ return wex.taskScheduler.isIdle();
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.Idle;
+ },
+ });
+}
+
/**
- * Wait until pending work is processed.
+ * Wait until all chosen transactions are in a final state.
*/
-export async function waitUntilTasksProcessed(
- ws: InternalWalletState,
+export async function waitUntilGivenTransactionsFinal(
+ wex: WalletExecutionContext,
+ transactionIds: string[],
): Promise<void> {
- logger.info("waiting until pending work is processed");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.PendingOperationProcessed) {
- p.resolve();
- }
- });
- while (1) {
- p = openPromise();
- const pendingTasksResp = await getPendingOperations(ws);
- logger.info(`waiting on pending ops: ${j2s(pendingTasksResp)}`);
- let finished = true;
- for (const task of pendingTasksResp.pendingOperations) {
- if (task.isDue) {
- finished = false;
- }
- logger.info(`continuing waiting for task ${task.id}`);
- }
- if (finished) {
- break;
- }
- // Wait until task is done
- await p.promise;
+ logger.info(
+ `waiting until given ${transactionIds.length} transactions are in a final state`,
+ );
+ logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
+ if (transactionIds.length === 0) {
+ return;
}
- logger.info("done waiting until pending work is processed");
- cancelNotifs();
+
+ const txIdSet = new Set(transactionIds);
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
+ if (!txIdSet.has(notif.transactionId)) {
+ return false;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ return false;
+ }
+ return true;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (!txIdSet.has(tx.transactionId)) {
+ // Don't look at this transaction, we're not interested in it.
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
+ }
+ // No transaction is pending, we're done waiting!
+ return true;
+ },
+ });
+ logger.info("done waiting until given transactions are in a final state");
}
export async function waitUntilRefreshesDone(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<void> {
logger.info("waiting until all refresh transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
- break;
+ return false;
default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (tx.type !== TransactionType.Refresh) {
- continue;
+ return true;
}
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refresh) {
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
}
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
+ return true;
+ },
+ });
logger.info("done waiting until all refreshes are in a final state");
}
async function waitUntilTransactionPendingReady(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
+ return await waitTransactionState(wex, transactionId, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
});
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major == TransactionMajorState.Pending &&
- tx.txState.minor === TransactionMinorState.Ready
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
- cancelNotifs();
}
/**
* Wait until a transaction is in a particular state.
*/
export async function waitTransactionState(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
txState: TransactionState,
): Promise<void> {
@@ -541,45 +580,50 @@ export async function waitTransactionState(
txState,
)})`,
);
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
+ await genericWaitForState(wex, {
+ async checkState() {
+ const tx = await getTransactionById(wex, {
+ transactionId,
+ });
+ return (
+ tx.txState.major === txState.major && tx.txState.minor === txState.minor
+ );
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.TransactionStateTransition;
+ },
});
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major === txState.major &&
- tx.txState.minor === txState.minor
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
logger.info(
`done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
);
- cancelNotifs();
+}
+
+export async function waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, transactionId),
+ );
+}
+
+export async function waitUntilTransactionFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
}
export async function runIntegrationTest2(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
args: IntegrationTestV2Args,
): Promise<void> {
- // FIXME: Make sure that a task look is running, since we're
- // waiting for notifications.
+ await wex.taskScheduler.ensureRunning();
logger.info("running test with arguments", args);
- const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl);
+ const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);
const currency = exchangeInfo.currency;
@@ -587,15 +631,15 @@ export async function runIntegrationTest2(
const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
logger.info("withdrawing test balance");
- await withdrawTestBalance(ws, {
+ const withdrawalRes = await withdrawTestBalance(wex, {
amount: Amounts.stringify(amountToWithdraw),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionFinal(wex, withdrawalRes.transactionId);
logger.info("done withdrawing test balance");
- const balance = await getBalances(ws);
+ const balance = await getBalances(wex);
logger.trace(JSON.stringify(balance, null, 2));
@@ -604,15 +648,17 @@ export async function runIntegrationTest2(
authToken: args.merchantAuthToken,
};
- await makePayment(
- ws,
+ const makePaymentRes = await makePayment(
+ wex,
myMerchant,
Amounts.stringify(amountToSpend),
"hello world",
);
- // Wait until the refresh is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
logger.trace("withdrawing test balance for refund");
const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
@@ -620,24 +666,24 @@ export async function runIntegrationTest2(
const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
- await withdrawTestBalance(ws, {
+ const withdrawalRes2 = await withdrawTestBalance(wex, {
amount: Amounts.stringify(withdrawAmountTwo),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
// Wait until the withdraw is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId);
const { orderId: refundOrderId } = await makePayment(
- ws,
+ wex,
myMerchant,
Amounts.stringify(spendAmountTwo),
"order that will be refunded",
);
const refundUri = await refund(
- ws.http,
+ wex.http,
myMerchant,
refundOrderId,
"test refund",
@@ -646,27 +692,33 @@ export async function runIntegrationTest2(
logger.trace("refund URI", refundUri);
- await startRefundQueryForUri(ws, refundUri);
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
logger.trace("integration test: applied refund");
// Wait until the refund is done
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
logger.trace("integration test: making payment after refund");
- await makePayment(
- ws,
+ const makePaymentRes2 = await makePayment(
+ wex,
myMerchant,
Amounts.stringify(spendAmountThree),
"payment after refund",
);
- logger.trace("integration test: make payment done");
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes2.paymentTransactionId,
+ );
- await waitUntilTransactionsFinal(ws);
+ logger.trace("integration test: make payment done");
- const peerPushInit = await initiatePeerPushDebit(ws, {
+ const peerPushInit = await initiatePeerPushDebit(wex, {
partialContractTerms: {
amount: `${currency}:1` as AmountString,
summary: "Payment Peer Push Test",
@@ -679,14 +731,8 @@ export async function runIntegrationTest2(
},
});
- await waitUntilTransactionPendingReady(ws, peerPushInit.transactionId);
- const talerUri = stringifyTalerUri({
- type: TalerUriAction.PayPush,
- exchangeBaseUrl: peerPushInit.exchangeBaseUrl,
- contractPriv: peerPushInit.contractPriv,
- });
-
- const txDetails = await getTransactionById(ws, {
+ await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId);
+ const txDetails = await getTransactionById(wex, {
transactionId: peerPushInit.transactionId,
});
@@ -698,15 +744,15 @@ export async function runIntegrationTest2(
throw Error("internal invariant failed");
}
- const peerPushCredit = await preparePeerPushCredit(ws, {
+ const peerPushCredit = await preparePeerPushCredit(wex, {
talerUri: txDetails.talerUri,
});
- await confirmPeerPushCredit(ws, {
+ await confirmPeerPushCredit(wex, {
transactionId: peerPushCredit.transactionId,
});
- const peerPullInit = await initiatePeerPullPayment(ws, {
+ const peerPullInit = await initiatePeerPullPayment(wex, {
partialContractTerms: {
amount: `${currency}:1` as AmountString,
summary: "Payment Peer Pull Test",
@@ -719,23 +765,60 @@ export async function runIntegrationTest2(
},
});
- await waitUntilTransactionPendingReady(ws, peerPullInit.transactionId);
+ await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId);
- const peerPullInc = await preparePeerPullDebit(ws, {
+ const peerPullInc = await preparePeerPullDebit(wex, {
talerUri: peerPullInit.talerUri,
});
- await confirmPeerPullDebit(ws, {
- peerPullDebitId: peerPullInc.peerPullDebitId,
+ await confirmPeerPullDebit(wex, {
+ transactionId: peerPullInc.transactionId,
});
- await waitUntilTransactionsFinal(ws);
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInc.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushCredit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushInit.transactionId,
+ );
+
+ let depositPayto = withdrawalRes.accountPaytoUri;
+
+ const parsedPayto = parsePaytoUri(depositPayto);
+ if (!parsedPayto) {
+ throw Error("invalid payto");
+ }
+
+ // Work around libeufin-bank bug where receiver-name is missing
+ if (!parsedPayto.params["receiver-name"]) {
+ depositPayto = addPaytoQueryParams(depositPayto, {
+ "receiver-name": "Test",
+ });
+ }
+
+ await createDepositGroup(wex, {
+ amount: `${currency}:5` as AmountString,
+ depositPaytoUri: depositPayto,
+ });
logger.trace("integration test: all done!");
}
export async function testPay(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
args: TestPayArgs,
): Promise<TestPayResult> {
logger.trace("creating order");
@@ -744,40 +827,45 @@ export async function testPay(
baseUrl: args.merchantBaseUrl,
};
const orderResp = await createOrder(
- ws.http,
+ wex.http,
merchant,
args.amount,
args.summary,
"taler://fulfillment-success/thank+you",
);
logger.trace("created new order with order ID", orderResp.orderId);
- const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
+ const checkPayResp = await checkPayment(
+ wex.http,
+ merchant,
+ orderResp.orderId,
+ );
const talerPayUri = checkPayResp.taler_pay_uri;
if (!talerPayUri) {
console.error("fatal: no taler pay URI received from backend");
process.exit(1);
}
logger.trace("taler pay URI:", talerPayUri);
- const result = await preparePayForUri(ws, talerPayUri);
+ const result = await preparePayForUri(wex, talerPayUri);
if (result.status !== PreparePayResultType.PaymentPossible) {
throw Error(`unexpected prepare pay status: ${result.status}`);
}
const r = await confirmPay(
- ws,
- result.proposalId,
+ wex,
+ result.transactionId,
undefined,
args.forcedCoinSel,
);
if (r.type != ConfirmPayResultType.Done) {
throw Error("payment not done");
}
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
return tx.purchases.get(result.proposalId);
- });
+ },
+ );
checkLogicInvariant(!!purchase);
return {
- payCoinSelection: purchase.payInfo?.payCoinSelection!,
+ numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
};
}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
index 9deb050d8..9a9fb524f 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -17,9 +17,12 @@
/**
* Imports.
*/
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
Amounts,
+ assertUnreachable,
+ checkDbInvariant,
DepositTransactionTrackingState,
j2s,
Logger,
@@ -28,11 +31,13 @@ import {
PeerContractTerms,
RefundInfoShort,
RefundPaymentInfo,
+ ScopeType,
stringifyPayPullUri,
stringifyPayPushUri,
TalerErrorCode,
TalerPreciseTimestamp,
Transaction,
+ TransactionAction,
TransactionByIdRequest,
TransactionIdStr,
TransactionMajorState,
@@ -41,136 +46,96 @@ import {
TransactionsResponse,
TransactionState,
TransactionType,
+ TransactionWithdrawal,
WalletContractData,
+ WithdrawalTransactionByURIRequest,
WithdrawalType,
} from "@gnu-taler/taler-util";
import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ TaskIdentifiers,
+ TaskIdStr,
+ TransactionContext,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
DepositElementStatus,
DepositGroupRecord,
- ExchangeDetailsRecord,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
OperationRetryRecord,
PeerPullCreditRecord,
PeerPullDebitRecordStatus,
PeerPullPaymentIncomingRecord,
PeerPushCreditStatus,
PeerPushDebitRecord,
+ PeerPushDebitStatus,
PeerPushPaymentIncomingRecord,
PurchaseRecord,
PurchaseStatus,
RefreshGroupRecord,
RefreshOperationStatus,
RefundGroupRecord,
- RewardRecord,
+ timestampPreciseFromDb,
+ timestampProtocolFromDb,
+ WalletDbReadOnlyTransaction,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
-} from "../db.js";
-import {
- GetReadOnlyAccess,
- PeerPushDebitStatus,
- timestampPreciseFromDb,
- timestampProtocolFromDb,
- WalletStoresV1,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- resetPendingTaskTimeout,
- TaskIdentifiers,
- TombstoneTag,
-} from "./common.js";
+} from "./db.js";
import {
- abortDepositGroup,
computeDepositTransactionActions,
computeDepositTransactionStatus,
- deleteDepositGroup,
- failDepositTransaction,
- resumeDepositGroup,
- suspendDepositGroup,
+ DepositTransactionContext,
} from "./deposits.js";
-import { getExchangeDetails } from "./exchanges.js";
import {
- abortPayMerchant,
+ computeDenomLossTransactionStatus,
+ DenomLossTransactionContext,
+ ExchangeWireDetails,
+ fetchFreshExchange,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import {
computePayMerchantTransactionActions,
computePayMerchantTransactionState,
computeRefundTransactionState,
expectProposalDownload,
extractContractData,
- failPaymentTransaction,
- resumePayMerchant,
- suspendPayMerchant,
+ PayMerchantTransactionContext,
+ RefundTransactionContext,
} from "./pay-merchant.js";
import {
- abortPeerPullCreditTransaction,
computePeerPullCreditTransactionActions,
computePeerPullCreditTransactionState,
- failPeerPullCreditTransaction,
- resumePeerPullCreditTransaction,
- suspendPeerPullCreditTransaction,
+ PeerPullCreditTransactionContext,
} from "./pay-peer-pull-credit.js";
import {
- abortPeerPullDebitTransaction,
computePeerPullDebitTransactionActions,
computePeerPullDebitTransactionState,
- failPeerPullDebitTransaction,
- resumePeerPullDebitTransaction,
- suspendPeerPullDebitTransaction,
+ PeerPullDebitTransactionContext,
} from "./pay-peer-pull-debit.js";
import {
- abortPeerPushCreditTransaction,
computePeerPushCreditTransactionActions,
computePeerPushCreditTransactionState,
- failPeerPushCreditTransaction,
- resumePeerPushCreditTransaction,
- suspendPeerPushCreditTransaction,
+ PeerPushCreditTransactionContext,
} from "./pay-peer-push-credit.js";
import {
- abortPeerPushDebitTransaction,
computePeerPushDebitTransactionActions,
computePeerPushDebitTransactionState,
- failPeerPushDebitTransaction,
- resumePeerPushDebitTransaction,
- suspendPeerPushDebitTransaction,
+ PeerPushDebitTransactionContext,
} from "./pay-peer-push-debit.js";
import {
- iterRecordsForDeposit,
- iterRecordsForPeerPullDebit,
- iterRecordsForPeerPullInitiation as iterRecordsForPeerPullCredit,
- iterRecordsForPeerPushCredit,
- iterRecordsForPeerPushInitiation as iterRecordsForPeerPushDebit,
- iterRecordsForPurchase,
- iterRecordsForRefresh,
- iterRecordsForRefund,
- iterRecordsForReward,
- iterRecordsForWithdrawal,
-} from "./pending.js";
-import {
- abortRefreshGroup,
computeRefreshTransactionActions,
computeRefreshTransactionState,
- failRefreshGroup,
- resumeRefreshGroup,
- suspendRefreshGroup,
+ RefreshTransactionContext,
} from "./refresh.js";
+import type { WalletExecutionContext } from "./wallet.js";
import {
- abortTipTransaction,
- computeRewardTransactionStatus,
- computeTipTransactionActions,
- failTipTransaction,
- resumeTipTransaction,
- suspendRewardTransaction,
-} from "./reward.js";
-import {
- abortWithdrawalTransaction,
augmentPaytoUrisForWithdrawal,
computeWithdrawalTransactionActions,
computeWithdrawalTransactionStatus,
- failWithdrawalTransaction,
- resumeWithdrawalTransaction,
- suspendWithdrawalTransaction,
+ WithdrawTransactionContext,
} from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts");
@@ -178,11 +143,39 @@ const logger = new Logger("taler-wallet-core:transactions.ts");
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
+ exchangesInTransaction: string[],
): boolean {
- if (!transactionsRequest?.currency) {
- return false;
+ if (transactionsRequest?.scopeInfo) {
+ const sameCurrency = Amounts.isSameCurrency(
+ currency,
+ transactionsRequest.scopeInfo.currency,
+ );
+ switch (transactionsRequest.scopeInfo.type) {
+ case ScopeType.Global: {
+ return !sameCurrency;
+ }
+ case ScopeType.Exchange: {
+ return (
+ !sameCurrency ||
+ (exchangesInTransaction.length > 0 &&
+ !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
+ );
+ }
+ case ScopeType.Auditor: {
+ // same currency and same auditor
+ throw Error("filering balance in auditor scope is not implemented");
+ }
+ default:
+ assertUnreachable(transactionsRequest.scopeInfo);
+ }
}
- return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
+ // FIXME: remove next release
+ if (transactionsRequest?.currency) {
+ return (
+ transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
+ );
+ }
+ return false;
}
function shouldSkipSearch(
@@ -206,7 +199,6 @@ function shouldSkipSearch(
*/
const txOrder: { [t in TransactionType]: number } = {
[TransactionType.Withdrawal]: 1,
- [TransactionType.Reward]: 2,
[TransactionType.Payment]: 3,
[TransactionType.PeerPullCredit]: 4,
[TransactionType.PeerPullDebit]: 5,
@@ -215,11 +207,13 @@ const txOrder: { [t in TransactionType]: number } = {
[TransactionType.Refund]: 8,
[TransactionType.Deposit]: 9,
[TransactionType.Refresh]: 10,
+ [TransactionType.Recoup]: 11,
[TransactionType.InternalWithdrawal]: 12,
+ [TransactionType.DenomLoss]: 13,
};
export async function getTransactionById(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: TransactionByIdRequest,
): Promise<Transaction> {
const parsedTx = parseTransactionIdentifier(req.transactionId);
@@ -232,65 +226,89 @@ export async function getTransactionById(
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal: {
const withdrawalGroupId = parsedTx.withdrawalGroupId;
- return await ws.db
- .mktx((x) => [
- x.withdrawalGroups,
- x.exchangeDetails,
- x.exchanges,
- x.operationRetries,
- ])
- .runReadWrite(async (tx) => {
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
if (!withdrawalGroupRecord) throw Error("not found");
const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
const ort = await tx.operationRetries.get(opId);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
if (
withdrawalGroupRecord.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
return buildTransactionForBankIntegratedWithdraw(
withdrawalGroupRecord,
+ exchangeDetails,
ort,
);
}
- const exchangeDetails = await getExchangeDetails(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
- if (!exchangeDetails) throw Error("not exchange details");
return buildTransactionForManualWithdraw(
withdrawalGroupRecord,
exchangeDetails,
ort,
);
- });
+ },
+ );
+ }
+
+ case TransactionType.DenomLoss: {
+ const rec = await wex.db.runReadOnlyTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ return tx.denomLossEvents.get(parsedTx.denomLossEventId);
+ },
+ );
+ if (!rec) {
+ throw Error("denom loss record not found");
+ }
+ return buildTransactionForDenomLoss(rec);
}
+ case TransactionType.Recoup:
+ throw new Error("not yet supported");
+
case TransactionType.Payment: {
const proposalId = parsedTx.proposalId;
- return await ws.db
- .mktx((x) => [
- x.purchases,
- x.tombstones,
- x.operationRetries,
- x.refundGroups,
- x.contractTerms,
- ])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "tombstones",
+ "operationRetries",
+ "contractTerms",
+ "refundGroups",
+ ],
+ },
+ async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) throw Error("not found");
- const download = await expectProposalDownload(ws, purchase, tx);
+ const download = await expectProposalDownload(wex, purchase, tx);
const contractData = download.contractData;
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(purchase.proposalId)
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
return buildTransactionForPurchase(
purchase,
@@ -298,34 +316,33 @@ export async function getTransactionById(
refunds,
payRetryRecord,
);
- });
+ },
+ );
}
case TransactionType.Refresh: {
- // FIXME: We should return info about the refresh here!
- throw Error(`no tx for refresh`);
- }
-
- case TransactionType.Reward: {
- const tipId = parsedTx.walletRewardId;
- return await ws.db
- .mktx((x) => [x.rewards, x.operationRetries])
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.rewards.get(tipId);
- if (!tipRecord) throw Error("not found");
-
+ // FIXME: We should return info about the refresh here!;
+ const refreshGroupId = parsedTx.refreshGroupId;
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "operationRetries"] },
+ async (tx) => {
+ const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroupRec) {
+ throw Error("not found");
+ }
const retries = await tx.operationRetries.get(
- TaskIdentifiers.forTipPickup(tipRecord),
+ TaskIdentifiers.forRefresh(refreshGroupRec),
);
- return buildTransactionForTip(tipRecord, retries);
- });
+ return buildTransactionForRefresh(refreshGroupRec, retries);
+ },
+ );
}
case TransactionType.Deposit: {
const depositGroupId = parsedTx.depositGroupId;
- return await ws.db
- .mktx((x) => [x.depositGroups, x.operationRetries])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "operationRetries"] },
+ async (tx) => {
const depositRecord = await tx.depositGroups.get(depositGroupId);
if (!depositRecord) throw Error("not found");
@@ -333,13 +350,21 @@ export async function getTransactionById(
TaskIdentifiers.forDeposit(depositRecord),
);
return buildTransactionForDeposit(depositRecord, retries);
- });
+ },
+ );
}
case TransactionType.Refund: {
- return await ws.db
- .mktx((x) => [x.refundGroups, x.contractTerms, x.purchases])
- .runReadOnly(async (tx) => {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "refundGroups",
+ "purchases",
+ "operationRetries",
+ "contractTerms",
+ ],
+ },
+ async (tx) => {
const refundRecord = await tx.refundGroups.get(
parsedTx.refundGroupId,
);
@@ -351,12 +376,13 @@ export async function getTransactionById(
refundRecord?.proposalId,
);
return buildTransactionForRefund(refundRecord, contractData);
- });
+ },
+ );
}
case TransactionType.PeerPullDebit: {
- return await ws.db
- .mktx((x) => [x.peerPullDebit, x.contractTerms])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
if (!debit) throw Error("not found");
const contractTermsRec = await tx.contractTerms.get(
@@ -368,13 +394,14 @@ export async function getTransactionById(
debit,
contractTermsRec.contractTermsRaw,
);
- });
+ },
+ );
}
case TransactionType.PeerPushDebit: {
- return await ws.db
- .mktx((x) => [x.peerPushDebit, x.contractTerms])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "contractTerms"] },
+ async (tx) => {
const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
if (!debit) throw Error("not found");
const ct = await tx.contractTerms.get(debit.contractTermsHash);
@@ -383,19 +410,22 @@ export async function getTransactionById(
debit,
ct.contractTermsRaw,
);
- });
+ },
+ );
}
case TransactionType.PeerPushCredit: {
const peerPushCreditId = parsedTx.peerPushCreditId;
- return await ws.db
- .mktx((x) => [
- x.peerPushCredit,
- x.contractTerms,
- x.withdrawalGroups,
- x.operationRetries,
- ])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
@@ -420,19 +450,22 @@ export async function getTransactionById(
wg,
wgOrt,
);
- });
+ },
+ );
}
case TransactionType.PeerPullCredit: {
const pursePub = parsedTx.pursePub;
- return await ws.db
- .mktx((x) => [
- x.peerPullCredit,
- x.contractTerms,
- x.withdrawalGroups,
- x.operationRetries,
- ])
- .runReadWrite(async (tx) => {
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPullCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
const pushInc = await tx.peerPullCredit.get(pursePub);
if (!pushInc) throw Error("not found");
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
@@ -458,7 +491,8 @@ export async function getTransactionById(
wg,
wgOrt,
);
- });
+ },
+ );
}
}
}
@@ -477,11 +511,14 @@ function buildTransactionForPushPaymentDebit(
contractPriv: pi.contractPriv,
});
}
+ const txState = computePeerPushDebitTransactionState(pi);
return {
type: TransactionType.PeerPushDebit,
- txState: computePeerPushDebitTransactionState(pi),
+ txState,
txActions: computePeerPushDebitTransactionActions(pi),
- amountEffective: pi.totalCost,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
+ : pi.totalCost,
amountRaw: pi.amount,
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
@@ -503,13 +540,16 @@ function buildTransactionForPullPaymentDebit(
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord,
): Transaction {
+ const txState = computePeerPullDebitTransactionState(pi);
return {
type: TransactionType.PeerPullDebit,
- txState: computePeerPullDebitTransactionState(pi),
+ txState,
txActions: computePeerPullDebitTransactionActions(pi),
- amountEffective: pi.coinSel?.totalCost
- ? pi.coinSel?.totalCost
- : Amounts.stringify(pi.amount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
+ : pi.coinSel?.totalCost
+ ? pi.coinSel?.totalCost
+ : Amounts.stringify(pi.amount),
amountRaw: Amounts.stringify(pi.amount),
exchangeBaseUrl: pi.exchangeBaseUrl,
info: {
@@ -544,18 +584,23 @@ function buildTransactionForPeerPullCredit(
const silentWithdrawalErrorForInvoice =
wsrOrt?.lastError &&
wsrOrt.lastError.code ===
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
return (
e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
e.httpStatusCode === 409
);
});
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized");
+ checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized");
return {
type: TransactionType.PeerPullCredit,
- txState: computePeerPullCreditTransactionState(pullCredit),
+ txState,
txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wsr.instructedAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
@@ -574,19 +619,22 @@ function buildTransactionForPeerPullCredit(
kycUrl: pullCredit.kycUrl,
...(wsrOrt?.lastError
? {
- error: silentWithdrawalErrorForInvoice
- ? undefined
- : wsrOrt.lastError,
- }
+ error: silentWithdrawalErrorForInvoice
+ ? undefined
+ : wsrOrt.lastError,
+ }
: {}),
};
}
+ const txState = computePeerPullCreditTransactionState(pullCredit);
return {
type: TransactionType.PeerPullCredit,
- txState: computePeerPullCreditTransactionState(pullCredit),
+ txState,
txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : Amounts.stringify(pullCredit.estimatedAmountEffective),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
@@ -611,26 +659,31 @@ function buildTransactionForPeerPushCredit(
pushInc: PeerPushPaymentIncomingRecord,
pushOrt: OperationRetryRecord | undefined,
peerContractTerms: PeerContractTerms,
- wsr: WithdrawalGroupRecord | undefined,
+ wg: WithdrawalGroupRecord | undefined,
wsrOrt: OperationRetryRecord | undefined,
): Transaction {
- if (wsr) {
- if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
+ if (wg) {
+ if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
throw Error("invalid withdrawal group type for push payment credit");
}
+ checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
+ checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
+ const txState = computePeerPushCreditTransactionState(pushInc);
return {
type: TransactionType.PeerPushCredit,
- txState: computePeerPushCreditTransactionState(pushInc),
+ txState,
txActions: computePeerPushCreditTransactionActions(pushInc),
- amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wsr.instructedAmount),
- exchangeBaseUrl: wsr.exchangeBaseUrl,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
+ : Amounts.stringify(wg.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wg.instructedAmount),
+ exchangeBaseUrl: wg.exchangeBaseUrl,
info: {
expiration: peerContractTerms.purse_expiration,
summary: peerContractTerms.summary,
},
- timestamp: timestampPreciseFromDb(wsr.timestampStart),
+ timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId: pushInc.peerPushCreditId,
@@ -640,12 +693,15 @@ function buildTransactionForPeerPushCredit(
};
}
+ const txState = computePeerPushCreditTransactionState(pushInc);
return {
type: TransactionType.PeerPushCredit,
- txState: computePeerPushCreditTransactionState(pushInc),
+ txState,
txActions: computePeerPushCreditTransactionActions(pushInc),
- // FIXME: This is wrong, needs to consider fees!
- amountEffective: Amounts.stringify(peerContractTerms.amount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : // FIXME: This is wrong, needs to consider fees!
+ Amounts.stringify(peerContractTerms.amount),
amountRaw: Amounts.stringify(peerContractTerms.amount),
exchangeBaseUrl: pushInc.exchangeBaseUrl,
info: {
@@ -663,79 +719,103 @@ function buildTransactionForPeerPushCredit(
}
function buildTransactionForBankIntegratedWithdraw(
- wgRecord: WithdrawalGroupRecord,
+ wg: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
ort?: OperationRetryRecord,
-): Transaction {
- if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
+): TransactionWithdrawal {
+ if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("");
-
+ }
+ const txState = computeWithdrawalTransactionStatus(wg);
+ const zero = Amounts.stringify(
+ Amounts.zeroOfCurrency(exchangeDetails.currency),
+ );
return {
type: TransactionType.Withdrawal,
- txState: computeWithdrawalTransactionStatus(wgRecord),
- txActions: computeWithdrawalTransactionActions(wgRecord),
- amountEffective: Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ txState,
+ txActions: computeWithdrawalTransactionActions(wg),
+ exchangeBaseUrl: wg.exchangeBaseUrl,
+ amountEffective:
+ isUnsuccessfulTransaction(txState) || !wg.denomsSel
+ ? zero
+ : Amounts.stringify(wg.denomsSel.totalCoinValue),
+ amountRaw: !wg.instructedAmount
+ ? zero
+ : Amounts.stringify(wg.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
- exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
- reservePub: wgRecord.reservePub,
- bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ confirmed: wg.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
+ exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
+ reservePub: wg.reservePub,
+ bankConfirmationUrl: wg.wgInfo.bankInfo.confirmUrl,
reserveIsReady:
- wgRecord.status === WithdrawalGroupStatus.Done ||
- wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ wg.status === WithdrawalGroupStatus.Done ||
+ wg.status === WithdrawalGroupStatus.PendingReady,
},
- kycUrl: wgRecord.kycUrl,
- exchangeBaseUrl: wgRecord.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ kycUrl: wg.kycUrl,
+ timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgRecord.withdrawalGroupId,
+ withdrawalGroupId: wg.withdrawalGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
+export function isUnsuccessfulTransaction(state: TransactionState): boolean {
+ return (
+ state.major === TransactionMajorState.Aborted ||
+ state.major === TransactionMajorState.Expired ||
+ state.major === TransactionMajorState.Aborting ||
+ state.major === TransactionMajorState.Deleted ||
+ state.major === TransactionMajorState.Failed
+ );
+}
+
function buildTransactionForManualWithdraw(
- withdrawalGroup: WithdrawalGroupRecord,
- exchangeDetails: ExchangeDetailsRecord,
+ wg: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
ort?: OperationRetryRecord,
-): Transaction {
- if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
+): TransactionWithdrawal {
+ if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
throw Error("");
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
+ checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
+ wg.reservePub,
+ wg.instructedAmount,
);
+ const txState = computeWithdrawalTransactionStatus(wg);
+
return {
type: TransactionType.Withdrawal,
- txState: computeWithdrawalTransactionStatus(withdrawalGroup),
- txActions: computeWithdrawalTransactionActions(withdrawalGroup),
- amountEffective: Amounts.stringify(
- withdrawalGroup.denomsSel.totalCoinValue,
- ),
- amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
+ txState,
+ txActions: computeWithdrawalTransactionActions(wg),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
+ : Amounts.stringify(wg.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wg.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.ManualTransfer,
- reservePub: withdrawalGroup.reservePub,
+ reservePub: wg.reservePub,
exchangePaytoUris,
- exchangeCreditAccountDetails: withdrawalGroup.wgInfo.exchangeCreditAccounts,
+ exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
reserveIsReady:
- withdrawalGroup.status === WithdrawalGroupStatus.Done ||
- withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
+ wg.status === WithdrawalGroupStatus.Done ||
+ wg.status === WithdrawalGroupStatus.PendingReady,
},
- kycUrl: withdrawalGroup.kycUrl,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
+ kycUrl: wg.kycUrl,
+ exchangeBaseUrl: wg.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wg.timestampStart),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ withdrawalGroupId: wg.withdrawalGroupId,
}),
...(ort?.lastError ? { error: ort.lastError } : {}),
};
@@ -755,9 +835,12 @@ function buildTransactionForRefund(
};
}
+ const txState = computeRefundTransactionState(refundRecord);
return {
type: TransactionType.Refund,
- amountEffective: refundRecord.amountEffective,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
+ : refundRecord.amountEffective,
amountRaw: refundRecord.amountRaw,
refundedTransactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
@@ -768,7 +851,7 @@ function buildTransactionForRefund(
tag: TransactionType.Refund,
refundGroupId: refundRecord.refundGroupId,
}),
- txState: computeRefundTransactionState(refundRecord),
+ txState,
txActions: [],
paymentInfo,
};
@@ -786,21 +869,21 @@ function buildTransactionForRefresh(
refreshGroupRecord.currency,
refreshGroupRecord.expectedOutputPerCoin,
).amount;
+ const txState = computeRefreshTransactionState(refreshGroupRecord);
return {
type: TransactionType.Refresh,
- txState: computeRefreshTransactionState(refreshGroupRecord),
+ txState,
txActions: computeRefreshTransactionActions(refreshGroupRecord),
refreshReason: refreshGroupRecord.reason,
- amountEffective: Amounts.stringify(
- Amounts.zeroOfCurrency(refreshGroupRecord.currency),
- ),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
+ : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
amountRaw: Amounts.stringify(
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
),
refreshInputAmount: Amounts.stringify(inputAmount),
refreshOutputAmount: Amounts.stringify(outputAmount),
- originatingTransactionId:
- refreshGroupRecord.reasonDetails?.originatingTransactionId,
+ originatingTransactionId: refreshGroupRecord.originatingTransactionId,
timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refresh,
@@ -810,15 +893,37 @@ function buildTransactionForRefresh(
};
}
+function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction {
+ const txState = computeDenomLossTransactionStatus(rec);
+ return {
+ type: TransactionType.DenomLoss,
+ txState,
+ txActions: [TransactionAction.Delete],
+ amountRaw: Amounts.stringify(rec.amount),
+ amountEffective: Amounts.stringify(rec.amount),
+ timestamp: timestampPreciseFromDb(rec.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rec.denomLossEventId,
+ }),
+ lossEventType: rec.eventType,
+ exchangeBaseUrl: rec.exchangeBaseUrl,
+ };
+}
+
function buildTransactionForDeposit(
dg: DepositGroupRecord,
ort?: OperationRetryRecord,
): Transaction {
let deposited = true;
- for (const d of dg.statusPerCoin) {
- if (d == DepositElementStatus.DepositPending) {
- deposited = false;
+ if (dg.statusPerCoin) {
+ for (const d of dg.statusPerCoin) {
+ if (d == DepositElementStatus.DepositPending) {
+ deposited = false;
+ }
}
+ } else {
+ deposited = false;
}
const trackingState: DepositTransactionTrackingState[] = [];
@@ -832,12 +937,26 @@ function buildTransactionForDeposit(
});
}
+ let wireTransferProgress = 0;
+ if (dg.statusPerCoin) {
+ wireTransferProgress =
+ (100 *
+ dg.statusPerCoin.reduce(
+ (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
+ 0,
+ )) /
+ dg.statusPerCoin.length;
+ }
+
+ const txState = computeDepositTransactionStatus(dg);
return {
type: TransactionType.Deposit,
- txState: computeDepositTransactionStatus(dg),
+ txState,
txActions: computeDepositTransactionActions(dg),
amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
- amountEffective: Amounts.stringify(dg.totalPayCost),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
+ : Amounts.stringify(dg.totalPayCost),
timestamp: timestampPreciseFromDb(dg.timestampCreated),
targetPaytoUri: dg.wire.payto_uri,
wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
@@ -845,13 +964,7 @@ function buildTransactionForDeposit(
tag: TransactionType.Deposit,
depositGroupId: dg.depositGroupId,
}),
- wireTransferProgress:
- (100 *
- dg.statusPerCoin.reduce(
- (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
- 0,
- )) /
- dg.statusPerCoin.length,
+ wireTransferProgress,
depositGroupId: dg.depositGroupId,
trackingState,
deposited,
@@ -859,33 +972,8 @@ function buildTransactionForDeposit(
};
}
-function buildTransactionForTip(
- tipRecord: RewardRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- checkLogicInvariant(!!tipRecord.acceptedTimestamp);
-
- return {
- type: TransactionType.Reward,
- txState: computeRewardTransactionStatus(tipRecord),
- txActions: computeTipTransactionActions(tipRecord),
- amountEffective: Amounts.stringify(tipRecord.rewardAmountEffective),
- amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
- timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: tipRecord.walletRewardId,
- }),
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
async function lookupMaybeContractData(
- tx: GetReadOnlyAccess<{
- purchases: typeof WalletStoresV1.purchases;
- contractTerms: typeof WalletStoresV1.contractTerms;
- }>,
+ tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
proposalId: string,
): Promise<WalletContractData | undefined> {
let contractData: WalletContractData | undefined = undefined;
@@ -908,16 +996,22 @@ async function lookupMaybeContractData(
return contractData;
}
-async function buildTransactionForPurchase(
+function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
refundsInfo: RefundGroupRecord[],
ort?: OperationRetryRecord,
-): Promise<Transaction> {
+): Transaction {
const zero = Amounts.zeroOfAmount(contractData.amount);
const info: OrderShortInfo = {
- merchant: contractData.merchant,
+ merchant: {
+ name: contractData.merchant.name,
+ address: contractData.merchant.address,
+ email: contractData.merchant.email,
+ jurisdiction: contractData.merchant.jurisdiction,
+ website: contractData.merchant.website,
+ },
orderId: contractData.orderId,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
@@ -928,26 +1022,31 @@ async function buildTransactionForPurchase(
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
- const refunds: RefundInfoShort[] = refundsInfo.map(r => ({
+ const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
amountEffective: r.amountEffective,
amountRaw: r.amountRaw,
- timestamp: TalerPreciseTimestamp.round(timestampPreciseFromDb(r.timestampCreated)),
+ timestamp: TalerPreciseTimestamp.round(
+ timestampPreciseFromDb(r.timestampCreated),
+ ),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Refund,
refundGroupId: r.refundGroupId,
- })
+ }),
}));
const timestamp = purchaseRecord.timestampAccept;
checkDbInvariant(!!timestamp);
checkDbInvariant(!!purchaseRecord.payInfo);
+ const txState = computePayMerchantTransactionState(purchaseRecord);
return {
type: TransactionType.Payment,
- txState: computePayMerchantTransactionState(purchaseRecord),
+ txState,
txActions: computePayMerchantTransactionActions(purchaseRecord),
amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(zero)
+ : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
totalRefundRaw: Amounts.stringify(zero), // FIXME!
totalRefundEffective: Amounts.stringify(zero), // FIXME!
refundPending:
@@ -969,11 +1068,63 @@ async function buildTransactionForPurchase(
};
}
+export async function getWithdrawalTransactionByUri(
+ wex: WalletExecutionContext,
+ request: WithdrawalTransactionByURIRequest,
+): Promise<TransactionWithdrawal | undefined> {
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ request.talerWithdrawUri,
+ );
+
+ if (!withdrawalGroupRecord) {
+ return undefined;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ }
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+}
+
/**
* Retrieve the full event history for this wallet.
*/
export async function getTransactions(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionsRequest?: TransactionsRequest,
): Promise<TransactionsResponse> {
const transactions: Transaction[] = [];
@@ -983,33 +1134,42 @@ export async function getTransactions(
filter.onlyState = transactionsRequest.filterByState;
}
- await ws.db
- .mktx((x) => [
- x.coins,
- x.denominations,
- x.depositGroups,
- x.exchangeDetails,
- x.exchanges,
- x.operationRetries,
- x.peerPullDebit,
- x.peerPushDebit,
- x.peerPushCredit,
- x.peerPullCredit,
- x.planchets,
- x.purchases,
- x.contractTerms,
- x.recoupGroups,
- x.rewards,
- x.tombstones,
- x.withdrawalGroups,
- x.refreshGroups,
- x.refundGroups,
- ])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "peerPullCredit",
+ "planchets",
+ "purchases",
+ "contractTerms",
+ "recoupGroups",
+ "rewards",
+ "tombstones",
+ "withdrawalGroups",
+ "refreshGroups",
+ "refundGroups",
+ "denomLossEvents",
+ ],
+ },
+ async (tx) => {
await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
-
- if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
@@ -1024,7 +1184,14 @@ export async function getTransactions(
await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
- if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
@@ -1058,7 +1225,10 @@ export async function getTransactions(
// Legacy transaction
return;
}
- if (shouldSkipCurrency(transactionsRequest, pi.currency)) {
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
+ ) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
@@ -1096,7 +1266,8 @@ export async function getTransactions(
await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
const currency = Amounts.currencyOf(pi.amount);
- if (shouldSkipCurrency(transactionsRequest, currency)) {
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
@@ -1129,7 +1300,23 @@ export async function getTransactions(
await iterRecordsForRefund(tx, filter, async (refundGroup) => {
const currency = Amounts.currencyOf(refundGroup.amountRaw);
- if (shouldSkipCurrency(transactionsRequest, currency)) {
+
+ const exchangesInTx: string[] = [];
+ const p = await tx.purchases.get(refundGroup.proposalId);
+ if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
+ //refund with no payment
+ return;
+ }
+
+ // FIXME: This is very slow, should become obsolete with materialized transactions.
+ for (const cp of p.payInfo.payCoinSelection.coinPubs) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
return;
}
const contractData = await lookupMaybeContractData(
@@ -1140,7 +1327,12 @@ export async function getTransactions(
});
await iterRecordsForRefresh(tx, filter, async (rg) => {
- if (shouldSkipCurrency(transactionsRequest, rg.currency)) {
+ const exchangesInTx = rg.infoPerExchange
+ ? Object.keys(rg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
+ ) {
return;
}
let required = false;
@@ -1161,9 +1353,18 @@ export async function getTransactions(
await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
if (
+ wsr.rawWithdrawalAmount === undefined ||
+ wsr.exchangeBaseUrl == undefined
+ ) {
+ // skip prepared withdrawals which has not been confirmed
+ return;
+ }
+ const exchangesInTx = [wsr.exchangeBaseUrl];
+ if (
shouldSkipCurrency(
transactionsRequest,
Amounts.currencyOf(wsr.rawWithdrawalAmount),
+ exchangesInTx,
)
) {
return;
@@ -1187,13 +1388,28 @@ export async function getTransactions(
// FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
// FIXME: Still report if requested with verbose option?
return;
- case WithdrawalRecordType.BankIntegrated:
+ case WithdrawalRecordType.BankIntegrated: {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wsr.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ // FIXME: report somehow
+ return;
+ }
+
transactions.push(
- buildTransactionForBankIntegratedWithdraw(wsr, ort),
+ buildTransactionForBankIntegratedWithdraw(
+ wsr,
+ exchangeDetails,
+ ort,
+ ),
);
return;
+ }
+
case WithdrawalRecordType.BankManual: {
- const exchangeDetails = await getExchangeDetails(
+ const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
wsr.exchangeBaseUrl,
);
@@ -1201,7 +1417,6 @@ export async function getTransactions(
// FIXME: report somehow
return;
}
-
transactions.push(
buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
);
@@ -1213,9 +1428,33 @@ export async function getTransactions(
}
});
+ await iterRecordsForDenomLoss(tx, filter, async (rec) => {
+ const amount = Amounts.parseOrThrow(rec.amount);
+ const exchangesInTx = [rec.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ transactions.push(buildTransactionForDenomLoss(rec));
+ });
+
await iterRecordsForDeposit(tx, filter, async (dg) => {
const amount = Amounts.parseOrThrow(dg.amount);
- if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
+ const exchangesInTx = dg.infoPerExchange
+ ? Object.keys(dg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
return;
}
const opId = TaskIdentifiers.forDeposit(dg);
@@ -1232,7 +1471,22 @@ export async function getTransactions(
if (!purchase.payInfo) {
return;
}
- if (shouldSkipCurrency(transactionsRequest, download.currency)) {
+
+ const exchangesInTx: string[] = [];
+ for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ download.currency,
+ exchangesInTx,
+ )
+ ) {
return;
}
const contractTermsRecord = await tx.contractTerms.get(
@@ -1258,10 +1512,12 @@ export async function getTransactions(
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(purchase.proposalId)
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
transactions.push(
- await buildTransactionForPurchase(
+ buildTransactionForPurchase(
purchase,
contractData,
refunds,
@@ -1269,24 +1525,8 @@ export async function getTransactions(
),
);
});
-
- await iterRecordsForReward(tx, filter, async (tipRecord) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
- )
- ) {
- return;
- }
- if (!tipRecord.acceptedTimestamp) {
- return;
- }
- const opId = TaskIdentifiers.forTipPickup(tipRecord);
- const retryRecord = await tx.operationRetries.get(opId);
- transactions.push(buildTransactionForTip(tipRecord, retryRecord));
- });
- });
+ },
+ );
// One-off checks, because of a bug where the wallet previously
// did not migrate the DB correctly and caused these amounts
@@ -1308,9 +1548,6 @@ export async function getTransactions(
x.txState.major === TransactionMajorState.Aborting ||
x.txState.major === TransactionMajorState.Dialog;
- const txPending = transactions.filter((x) => isPending(x));
- const txNotPending = transactions.filter((x) => !isPending(x));
-
let sortSign: number;
if (transactionsRequest?.sort == "descending") {
sortSign = -1;
@@ -1331,10 +1568,18 @@ export async function getTransactions(
return sortSign * tsCmp;
};
+ if (transactionsRequest?.sort === "stable-ascending") {
+ transactions.sort(txCmp);
+ return { transactions };
+ }
+
+ const txPending = transactions.filter((x) => isPending(x));
+ const txNotPending = transactions.filter((x) => !isPending(x));
+
txPending.sort(txCmp);
txNotPending.sort(txCmp);
- return { transactions: [...txNotPending, ...txPending] };
+ return { transactions: [...txPending, ...txNotPending] };
}
export type ParsedTransactionIdentifier =
@@ -1346,9 +1591,10 @@ export type ParsedTransactionIdentifier =
| { tag: TransactionType.PeerPushDebit; pursePub: string }
| { tag: TransactionType.Refresh; refreshGroupId: string }
| { tag: TransactionType.Refund; refundGroupId: string }
- | { tag: TransactionType.Reward; walletRewardId: string }
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
- | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string };
+ | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.Recoup; recoupGroupId: string }
+ | { tag: TransactionType.DenomLoss; denomLossEventId: string };
export function constructTransactionIdentifier(
pTxId: ParsedTransactionIdentifier,
@@ -1370,12 +1616,14 @@ export function constructTransactionIdentifier(
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
case TransactionType.Refund:
return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
- case TransactionType.Reward:
- return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
case TransactionType.Withdrawal:
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
case TransactionType.InternalWithdrawal:
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.Recoup:
+ return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
+ case TransactionType.DenomLoss:
+ return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr;
default:
assertUnreachable(pTxId);
}
@@ -1425,40 +1673,24 @@ export function parseTransactionIdentifier(
tag: TransactionType.Refund,
refundGroupId: rest[0],
};
- case TransactionType.Reward:
- return {
- tag: TransactionType.Reward,
- walletRewardId: rest[0],
- };
case TransactionType.Withdrawal:
return {
tag: TransactionType.Withdrawal,
withdrawalGroupId: rest[0],
};
+ case TransactionType.DenomLoss:
+ return {
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rest[0],
+ };
default:
return undefined;
}
}
-export function stopLongpolling(ws: InternalWalletState, taskId: string) {
- const longpoll = ws.activeLongpoll[taskId];
- if (longpoll) {
- logger.info(`cancelling long-polling for ${taskId}`);
- longpoll.cancel();
- delete ws.activeLongpoll[taskId];
- }
-}
-
-/**
- * Immediately retry the underlying operation
- * of a transaction.
- */
-export async function retryTransaction(
- ws: InternalWalletState,
+function maybeTaskFromTransaction(
transactionId: string,
-): Promise<void> {
- logger.info(`resetting retry timeout for ${transactionId}`);
-
+): TaskIdStr | undefined {
const parsedTx = parseTransactionIdentifier(transactionId);
if (!parsedTx) {
@@ -1468,482 +1700,176 @@ export async function retryTransaction(
// FIXME: We currently don't cancel active long-polling tasks here.
switch (parsedTx.tag) {
- case TransactionType.PeerPullCredit: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.PeerPullCredit:
+ return constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub: parsedTx.pursePub,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.Deposit: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.Deposit:
+ return constructTaskIdentifier({
tag: PendingTaskType.Deposit,
depositGroupId: parsedTx.depositGroupId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal: {
- // FIXME: Abort current long-poller!
- const taskId = constructTaskIdentifier({
+ case TransactionType.Withdrawal:
+ return constructTaskIdentifier({
tag: PendingTaskType.Withdraw,
withdrawalGroupId: parsedTx.withdrawalGroupId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.Payment: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.Payment:
+ return constructTaskIdentifier({
tag: PendingTaskType.Purchase,
proposalId: parsedTx.proposalId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.Reward: {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: parsedTx.walletRewardId,
- });
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.Refresh: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.Refresh:
+ return constructTaskIdentifier({
tag: PendingTaskType.Refresh,
refreshGroupId: parsedTx.refreshGroupId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.PeerPullDebit: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.PeerPullDebit:
+ return constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullDebitId: parsedTx.peerPullDebitId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.PeerPushCredit: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.PeerPushCredit:
+ return constructTaskIdentifier({
tag: PendingTaskType.PeerPushCredit,
peerPushCreditId: parsedTx.peerPushCreditId,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
- case TransactionType.PeerPushDebit: {
- const taskId = constructTaskIdentifier({
+ case TransactionType.PeerPushDebit:
+ return constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub: parsedTx.pursePub,
});
- await resetPendingTaskTimeout(ws, taskId);
- stopLongpolling(ws, taskId);
- break;
- }
case TransactionType.Refund:
// Nothing to do for a refund transaction.
- break;
+ return undefined;
+ case TransactionType.Recoup:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: parsedTx.recoupGroupId,
+ });
+ case TransactionType.DenomLoss:
+ // Nothing to do for denom loss
+ return undefined;
default:
assertUnreachable(parsedTx);
}
}
/**
- * Suspends a pending transaction, stopping any associated network activities,
- * but with a chance of trying again at a later time. This could be useful if
- * a user needs to save battery power or bandwidth and an operation is expected
- * to take longer (such as a backup, recovery or very large withdrawal operation).
+ * Immediately retry the underlying operation
+ * of a transaction.
*/
-export async function suspendTransaction(
- ws: InternalWalletState,
+export async function retryTransaction(
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
+ logger.info(`resetting retry timeout for ${transactionId}`);
+ const taskId = maybeTaskFromTransaction(transactionId);
+ if (taskId) {
+ await wex.taskScheduler.resetTaskRetries(taskId);
+ }
+}
+
+export async function retryAll(wex: WalletExecutionContext): Promise<void> {
+ const tasks = wex.taskScheduler.getActiveTasks();
+ for (const task of tasks) {
+ await wex.taskScheduler.resetTaskRetries(task);
+ }
+}
+
+async function getContextForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<TransactionContext> {
const tx = parseTransactionIdentifier(transactionId);
if (!tx) {
throw Error("invalid transaction ID");
}
switch (tx.tag) {
case TransactionType.Deposit:
- await suspendDepositGroup(ws, tx.depositGroupId);
- return;
+ return new DepositTransactionContext(wex, tx.depositGroupId);
case TransactionType.Refresh:
- await suspendRefreshGroup(ws, tx.refreshGroupId);
- return;
+ return new RefreshTransactionContext(wex, tx.refreshGroupId);
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal:
- await suspendWithdrawalTransaction(ws, tx.withdrawalGroupId);
- return;
+ return new WithdrawTransactionContext(wex, tx.withdrawalGroupId);
case TransactionType.Payment:
- await suspendPayMerchant(ws, tx.proposalId);
- return;
+ return new PayMerchantTransactionContext(wex, tx.proposalId);
case TransactionType.PeerPullCredit:
- await suspendPeerPullCreditTransaction(ws, tx.pursePub);
- break;
+ return new PeerPullCreditTransactionContext(wex, tx.pursePub);
case TransactionType.PeerPushDebit:
- await suspendPeerPushDebitTransaction(ws, tx.pursePub);
- break;
+ return new PeerPushDebitTransactionContext(wex, tx.pursePub);
case TransactionType.PeerPullDebit:
- await suspendPeerPullDebitTransaction(ws, tx.peerPullDebitId);
- break;
+ return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId);
case TransactionType.PeerPushCredit:
- await suspendPeerPushCreditTransaction(ws, tx.peerPushCreditId);
- break;
+ return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId);
case TransactionType.Refund:
- throw Error("refund transactions can't be suspended or resumed");
- case TransactionType.Reward:
- await suspendRewardTransaction(ws, tx.walletRewardId);
- break;
+ return new RefundTransactionContext(wex, tx.refundGroupId);
+ case TransactionType.Recoup:
+ //return new RecoupTransactionContext(ws, tx.recoupGroupId);
+ throw new Error("not yet supported");
+ case TransactionType.DenomLoss:
+ return new DenomLossTransactionContext(wex, tx.denomLossEventId);
default:
assertUnreachable(tx);
}
}
+/**
+ * Suspends a pending transaction, stopping any associated network activities,
+ * but with a chance of trying again at a later time. This could be useful if
+ * a user needs to save battery power or bandwidth and an operation is expected
+ * to take longer (such as a backup, recovery or very large withdrawal operation).
+ */
+export async function suspendTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.suspendTransaction();
+}
+
export async function failTransaction(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- const tx = parseTransactionIdentifier(transactionId);
- if (!tx) {
- throw Error("invalid transaction ID");
- }
- switch (tx.tag) {
- case TransactionType.Deposit:
- await failDepositTransaction(ws, tx.depositGroupId);
- return;
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- await failWithdrawalTransaction(ws, tx.withdrawalGroupId);
- return;
- case TransactionType.Payment:
- await failPaymentTransaction(ws, tx.proposalId);
- return;
- case TransactionType.Refund:
- throw Error("can't do cancel-aborting on refund transaction");
- case TransactionType.Reward:
- await failTipTransaction(ws, tx.walletRewardId);
- return;
- case TransactionType.Refresh:
- await failRefreshGroup(ws, tx.refreshGroupId);
- return;
- case TransactionType.PeerPullCredit:
- await failPeerPullCreditTransaction(ws, tx.pursePub);
- return;
- case TransactionType.PeerPullDebit:
- await failPeerPullDebitTransaction(ws, tx.peerPullDebitId);
- return;
- case TransactionType.PeerPushCredit:
- await failPeerPushCreditTransaction(ws, tx.peerPushCreditId);
- return;
- case TransactionType.PeerPushDebit:
- await failPeerPushDebitTransaction(ws, tx.pursePub);
- return;
- default:
- assertUnreachable(tx);
- }
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.failTransaction();
}
/**
* Resume a suspended transaction.
*/
export async function resumeTransaction(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- const tx = parseTransactionIdentifier(transactionId);
- if (!tx) {
- throw Error("invalid transaction ID");
- }
- switch (tx.tag) {
- case TransactionType.Deposit:
- await resumeDepositGroup(ws, tx.depositGroupId);
- return;
- case TransactionType.Refresh:
- await resumeRefreshGroup(ws, tx.refreshGroupId);
- return;
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- await resumeWithdrawalTransaction(ws, tx.withdrawalGroupId);
- return;
- case TransactionType.Payment:
- await resumePayMerchant(ws, tx.proposalId);
- return;
- case TransactionType.PeerPullCredit:
- await resumePeerPullCreditTransaction(ws, tx.pursePub);
- break;
- case TransactionType.PeerPushDebit:
- await resumePeerPushDebitTransaction(ws, tx.pursePub);
- break;
- case TransactionType.PeerPullDebit:
- await resumePeerPullDebitTransaction(ws, tx.peerPullDebitId);
- break;
- case TransactionType.PeerPushCredit:
- await resumePeerPushCreditTransaction(ws, tx.peerPushCreditId);
- break;
- case TransactionType.Refund:
- throw Error("refund transactions can't be suspended or resumed");
- case TransactionType.Reward:
- await resumeTipTransaction(ws, tx.walletRewardId);
- break;
- }
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.resumeTransaction();
}
/**
* Permanently delete a transaction based on the transaction ID.
*/
export async function deleteTransaction(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- const parsedTx = parseTransactionIdentifier(transactionId);
-
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
-
- switch (parsedTx.tag) {
- case TransactionType.PeerPushCredit: {
- const peerPushCreditId = parsedTx.peerPushCreditId;
- await ws.db
- .mktx((x) => [x.withdrawalGroups, x.peerPushCredit, x.tombstones])
- .runReadWrite(async (tx) => {
- const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushInc) {
- return;
- }
- if (pushInc.withdrawalGroupId) {
- const withdrawalGroupId = pushInc.withdrawalGroupId;
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id:
- TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPushCredit.delete(peerPushCreditId);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
- });
- });
- return;
- }
-
- case TransactionType.PeerPullCredit: {
- const pursePub = parsedTx.pursePub;
- await ws.db
- .mktx((x) => [x.withdrawalGroups, x.peerPullCredit, x.tombstones])
- .runReadWrite(async (tx) => {
- const pullIni = await tx.peerPullCredit.get(pursePub);
- if (!pullIni) {
- return;
- }
- if (pullIni.withdrawalGroupId) {
- const withdrawalGroupId = pullIni.withdrawalGroupId;
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id:
- TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPullCredit.delete(pursePub);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
- });
- });
-
- return;
- }
-
- case TransactionType.Withdrawal: {
- const withdrawalGroupId = parsedTx.withdrawalGroupId;
- await ws.db
- .mktx((x) => [x.withdrawalGroups, x.tombstones])
- .runReadWrite(async (tx) => {
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- });
- return;
- }
-
- case TransactionType.Payment: {
- const proposalId = parsedTx.proposalId;
- await ws.db
- .mktx((x) => [x.purchases, x.tombstones])
- .runReadWrite(async (tx) => {
- let found = false;
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
- return;
- }
-
- case TransactionType.Refresh: {
- const refreshGroupId = parsedTx.refreshGroupId;
- await ws.db
- .mktx((x) => [x.refreshGroups, x.tombstones])
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (rg) {
- await tx.refreshGroups.delete(refreshGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
- });
- }
- });
-
- return;
- }
-
- case TransactionType.Reward: {
- const tipId = parsedTx.walletRewardId;
- await ws.db
- .mktx((x) => [x.rewards, x.tombstones])
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.rewards.get(tipId);
- if (tipRecord) {
- await tx.rewards.delete(tipId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteReward + ":" + tipId,
- });
- }
- });
- return;
- }
-
- case TransactionType.Deposit: {
- const depositGroupId = parsedTx.depositGroupId;
- await deleteDepositGroup(ws, depositGroupId);
- return;
- }
-
- case TransactionType.Refund: {
- const refundGroupId = parsedTx.refundGroupId;
- await ws.db
- .mktx((x) => [x.refundGroups, x.tombstones])
- .runReadWrite(async (tx) => {
- const refundRecord = await tx.refundGroups.get(refundGroupId);
- if (!refundRecord) {
- return;
- }
- await tx.refundGroups.delete(refundGroupId);
- await tx.tombstones.put({ id: transactionId });
- // FIXME: Also tombstone the refund items, so that they won't reappear.
- });
- return;
- }
-
- case TransactionType.PeerPullDebit: {
- const peerPullDebitId = parsedTx.peerPullDebitId;
- await ws.db
- .mktx((x) => [x.peerPullDebit, x.tombstones])
- .runReadWrite(async (tx) => {
- const debit = await tx.peerPullDebit.get(peerPullDebitId);
- if (debit) {
- await tx.peerPullDebit.delete(peerPullDebitId);
- await tx.tombstones.put({ id: transactionId });
- }
- });
-
- return;
- }
-
- case TransactionType.PeerPushDebit: {
- const pursePub = parsedTx.pursePub;
- await ws.db
- .mktx((x) => [x.peerPushDebit, x.tombstones])
- .runReadWrite(async (tx) => {
- const debit = await tx.peerPushDebit.get(pursePub);
- if (debit) {
- await tx.peerPushDebit.delete(pursePub);
- await tx.tombstones.put({ id: transactionId });
- }
- });
- return;
- }
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.deleteTransaction();
+ if (ctx.taskId) {
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
}
}
export async function abortTransaction(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- const txId = parseTransactionIdentifier(transactionId);
- if (!txId) {
- throw Error("invalid transaction identifier");
- }
-
- switch (txId.tag) {
- case TransactionType.Payment: {
- await abortPayMerchant(ws, txId.proposalId);
- break;
- }
- case TransactionType.Withdrawal:
- case TransactionType.InternalWithdrawal: {
- await abortWithdrawalTransaction(ws, txId.withdrawalGroupId);
- break;
- }
- case TransactionType.Deposit:
- await abortDepositGroup(ws, txId.depositGroupId);
- break;
- case TransactionType.Reward:
- await abortTipTransaction(ws, txId.walletRewardId);
- break;
- case TransactionType.Refund:
- throw Error("can't abort refund transactions");
- case TransactionType.Refresh:
- await abortRefreshGroup(ws, txId.refreshGroupId);
- break;
- case TransactionType.PeerPullCredit:
- await abortPeerPullCreditTransaction(ws, txId.pursePub);
- break;
- case TransactionType.PeerPullDebit:
- await abortPeerPullDebitTransaction(ws, txId.peerPullDebitId);
- break;
- case TransactionType.PeerPushCredit:
- await abortPeerPushCreditTransaction(ws, txId.peerPushCreditId);
- break;
- case TransactionType.PeerPushDebit:
- await abortPeerPushDebitTransaction(ws, txId.pursePub);
- break;
- default: {
- assertUnreachable(txId);
- }
- }
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.abortTransaction();
}
export interface TransitionInfo {
@@ -1955,7 +1881,7 @@ export interface TransitionInfo {
* Notify of a state transition if necessary.
*/
export function notifyTransition(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
transactionId: string,
transitionInfo: TransitionInfo | undefined,
experimentalUserData: any = undefined,
@@ -1967,7 +1893,7 @@ export function notifyTransition(
transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
)
) {
- ws.notify({
+ wex.ws.notify({
type: NotificationType.TransactionStateTransition,
oldTxState: transitionInfo.oldTxState,
newTxState: transitionInfo.newTxState,
@@ -1975,5 +1901,188 @@ export function notifyTransition(
experimentalUserData,
});
}
- ws.workAvailable.trigger();
+}
+
+/**
+ * Iterate refresh records based on a filter.
+ */
+async function iterRecordsForRefresh(
+ tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefreshGroupRecord) => Promise<void>,
+): Promise<void> {
+ let refreshGroups: RefreshGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ RefreshOperationStatus.Pending,
+ RefreshOperationStatus.Suspended,
+ );
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
+ }
+
+ for (const r of refreshGroups) {
+ await f(r);
+ }
+}
+
+async function iterRecordsForWithdrawal(
+ tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: WithdrawalGroupRecord) => Promise<void>,
+): Promise<void> {
+ let withdrawalGroupRecords: WithdrawalGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll();
+ }
+ for (const wgr of withdrawalGroupRecords) {
+ await f(wgr);
+ }
+}
+
+async function iterRecordsForDeposit(
+ tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DepositGroupRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DepositGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.depositGroups.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForDenomLoss(
+ tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DenomLossEventRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DenomLossEventRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForRefund(
+ tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefundGroupRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.refundGroups.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPurchase(
+ tx: WalletDbReadOnlyTransaction<["purchases"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PurchaseRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullCreditRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushDebitRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
}
diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-core/src/util/assertUnreachable.ts
deleted file mode 100644
index 1819fd09e..000000000
--- a/packages/taler-wallet-core/src/util/assertUnreachable.ts
+++ /dev/null
@@ -1,19 +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/>
- */
-
-export function assertUnreachable(x: never): never {
- throw new Error(`Didn't expect to get here ${x}`);
-}
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
deleted file mode 100644
index e3fbffe98..000000000
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ /dev/null
@@ -1,1236 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Selection of coins for payments.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AbsoluteTime,
- AccountRestriction,
- AgeCommitmentProof,
- AgeRestriction,
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- AmountJson,
- AmountLike,
- Amounts,
- AmountString,
- CoinPublicKeyString,
- CoinStatus,
- DenominationInfo,
- DenominationPubKey,
- DenomSelectionState,
- Duration,
- ForcedCoinSel,
- ForcedDenomSel,
- InternationalizedString,
- j2s,
- Logger,
- parsePaytoUri,
- PayCoinSelection,
- PayMerchantInsufficientBalanceDetails,
- PayPeerInsufficientBalanceDetails,
- strcmp,
- TalerProtocolTimestamp,
- UnblindedSignature,
-} from "@gnu-taler/taler-util";
-import { DenominationRecord } from "../db.js";
-import {
- getAutoRefreshExecuteThreshold,
- getExchangeDetails,
- isWithdrawableDenom,
- WalletDbReadOnlyTransaction,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- getMerchantPaymentBalanceDetails,
- getPeerPaymentBalanceDetailsInTx,
-} from "../operations/balance.js";
-import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
-
-const logger = new Logger("coinSelection.ts");
-
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- *
- * FIXME: We should only need the denomPubHash here, if at all.
- */
- denomPub: DenominationPubKey;
-
- /**
- * Full value of the coin.
- */
- value: AmountJson;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-
- exchangeBaseUrl: string;
-
- maxAge: number;
- ageCommitmentProof?: AgeCommitmentProof;
-}
-
-export type PreviousPayCoins = {
- coinPub: string;
- contribution: AmountJson;
- feeDeposit: AmountJson;
- exchangeBaseUrl: string;
-}[];
-
-export interface CoinCandidateSelection {
- candidateCoins: AvailableCoinInfo[];
- wireFeesPerExchange: Record<string, AmountJson>;
-}
-
-export interface SelectPayCoinRequest {
- candidates: CoinCandidateSelection;
- contractTermsAmount: AmountJson;
- depositFeeLimit: AmountJson;
- wireFeeLimit: AmountJson;
- wireFeeAmortization: number;
- prevPayCoins?: PreviousPayCoins;
- requiredMinimumAge?: number;
-}
-
-export interface CoinSelectionTally {
- /**
- * Amount that still needs to be paid.
- * May increase during the computation when fees need to be covered.
- */
- amountPayRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards wire fees
- */
- amountWireFeeLimitRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards deposit fees
- * (and wire fees after wire fee limit is exhausted)
- */
- amountDepositFeeLimitRemaining: AmountJson;
-
- customerDepositFees: AmountJson;
-
- customerWireFees: AmountJson;
-
- wireFeeCoveredForExchange: Set<string>;
-
- lastDepositFee: AmountJson;
-}
-
-/**
- * Account for the fees of spending a coin.
- */
-function tallyFees(
- tally: Readonly<CoinSelectionTally>,
- wireFeesPerExchange: Record<string, AmountJson>,
- wireFeeAmortization: number,
- exchangeBaseUrl: string,
- feeDeposit: AmountJson,
-): CoinSelectionTally {
- const currency = tally.amountPayRemaining.currency;
- let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining;
- let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining;
- let customerDepositFees = tally.customerDepositFees;
- let customerWireFees = tally.customerWireFees;
- let amountPayRemaining = tally.amountPayRemaining;
- const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange);
-
- if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
- const wf =
- wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
- const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf);
- amountWireFeeLimitRemaining = Amounts.sub(
- amountWireFeeLimitRemaining,
- wfForgiven,
- ).amount;
- // The remaining, amortized amount needs to be paid by the
- // wallet or covered by the deposit fee allowance.
- let wfRemaining = Amounts.divide(
- Amounts.sub(wf, wfForgiven).amount,
- wireFeeAmortization,
- );
-
- // This is the amount forgiven via the deposit fee allowance.
- const wfDepositForgiven = Amounts.min(
- amountDepositFeeLimitRemaining,
- wfRemaining,
- );
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- wfDepositForgiven,
- ).amount;
-
- wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
- customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount;
-
- wireFeeCoveredForExchange.add(exchangeBaseUrl);
- }
-
- const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining);
-
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- dfForgiven,
- ).amount;
-
- // How much does the user spend on deposit fees for this coin?
- const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
- customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount;
-
- return {
- amountDepositFeeLimitRemaining,
- amountPayRemaining,
- amountWireFeeLimitRemaining,
- customerDepositFees,
- customerWireFees,
- wireFeeCoveredForExchange,
- lastDepositFee: feeDeposit,
- };
-}
-
-export type SelectPayCoinsResult =
- | {
- type: "failure";
- insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
- }
- | { type: "success"; coinSel: PayCoinSelection };
-
-/**
- * Given a list of candidate coins, select coins to spend under the merchant's
- * constraints.
- *
- * The prevPayCoins can be specified to "repair" a coin selection
- * by adding additional coins, after a broken (e.g. double-spent) coin
- * has been removed from the selection.
- *
- * This function is only exported for the sake of unit tests.
- */
-export async function selectPayCoinsNew(
- ws: InternalWalletState,
- req: SelectPayCoinRequestNg,
-): Promise<SelectPayCoinsResult> {
- const {
- contractTermsAmount,
- depositFeeLimit,
- wireFeeLimit,
- wireFeeAmortization,
- } = req;
-
- // FIXME: Why don't we do this in a transaction?
- const [candidateDenoms, wireFeesPerExchange] =
- await selectPayMerchantCandidates(ws, req);
-
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- const currency = contractTermsAmount.currency;
-
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountWireFeeLimitRemaining: wireFeeLimit,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.zeroOfCurrency(currency),
- customerWireFees: Amounts.zeroOfCurrency(currency),
- wireFeeCoveredForExchange: new Set(),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
-
- const prevPayCoins = req.prevPayCoins ?? [];
-
- // Look at existing pay coin selection and tally up
- for (const prev of prevPayCoins) {
- tally = tallyFees(
- tally,
- wireFeesPerExchange,
- wireFeeAmortization,
- prev.exchangeBaseUrl,
- prev.feeDeposit,
- );
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- prev.contribution,
- ).amount;
-
- coinPubs.push(prev.coinPub);
- coinContributions.push(prev.contribution);
- }
-
- let selectedDenom: SelResult | undefined;
- if (req.forcedSelection) {
- selectedDenom = selectForced(req, candidateDenoms);
- } else {
- // FIXME: Here, we should select coins in a smarter way.
- // Instead of always spending the next-largest coin,
- // we should try to find the smallest coin that covers the
- // amount.
- selectedDenom = selectGreedy(
- req,
- candidateDenoms,
- wireFeesPerExchange,
- tally,
- );
- }
-
- if (!selectedDenom) {
- const details = await getMerchantPaymentBalanceDetails(ws, {
- acceptedAuditors: req.auditors,
- acceptedExchanges: req.exchanges,
- acceptedWireMethods: [req.wireMethod],
- currency: Amounts.currencyOf(req.contractTermsAmount),
- minAge: req.requiredMinimumAge ?? 0,
- });
- let feeGapEstimate: AmountJson;
- if (
- Amounts.cmp(
- details.balanceMerchantDepositable,
- req.contractTermsAmount,
- ) >= 0
- ) {
- // FIXME: We can probably give a better estimate.
- feeGapEstimate = Amounts.add(
- tally.amountPayRemaining,
- tally.lastDepositFee,
- ).amount;
- } else {
- feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount);
- }
- return {
- type: "failure",
- insufficientBalanceDetails: {
- amountRequested: Amounts.stringify(req.contractTermsAmount),
- balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
- balanceAvailable: Amounts.stringify(details.balanceAvailable),
- balanceMaterial: Amounts.stringify(details.balanceMaterial),
- balanceMerchantAcceptable: Amounts.stringify(
- details.balanceMerchantAcceptable,
- ),
- balanceMerchantDepositable: Amounts.stringify(
- details.balanceMerchantDepositable,
- ),
- feeGapEstimate: Amounts.stringify(feeGapEstimate),
- },
- };
- }
-
- const finalSel = selectedDenom;
-
- logger.trace(`coin selection request ${j2s(req)}`);
- logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
-
- await ws.db
- .mktx((x) => [x.coins, x.denominations])
- .runReadOnly(async (tx) => {
- for (const dph of Object.keys(finalSel)) {
- const selInfo = finalSel[dph];
- const numRequested = selInfo.contributions.length;
- const query = [
- selInfo.exchangeBaseUrl,
- selInfo.denomPubHash,
- selInfo.maxAge,
- CoinStatus.Fresh,
- ];
- logger.trace(`query: ${j2s(query)}`);
- const coins =
- await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
- query,
- numRequested,
- );
- if (coins.length != numRequested) {
- throw Error(
- `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
- );
- }
- coinPubs.push(...coins.map((x) => x.coinPub));
- coinContributions.push(...selInfo.contributions);
- }
- });
-
- return {
- type: "success",
- coinSel: {
- paymentAmount: Amounts.stringify(contractTermsAmount),
- coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
- coinPubs,
- customerDepositFees: Amounts.stringify(tally.customerDepositFees),
- customerWireFees: Amounts.stringify(tally.customerWireFees),
- },
- };
-}
-
-function makeAvailabilityKey(
- exchangeBaseUrl: string,
- denomPubHash: string,
- maxAge: number,
-): string {
- return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
-}
-
-/**
- * Selection result.
- */
-interface SelResult {
- /**
- * Map from an availability key
- * to an array of contributions.
- */
- [avKey: string]: {
- exchangeBaseUrl: string;
- denomPubHash: string;
- expireWithdraw: TalerProtocolTimestamp;
- expireDeposit: TalerProtocolTimestamp;
- maxAge: number;
- contributions: AmountJson[];
- };
-}
-
-export function testing_selectGreedy(
- ...args: Parameters<typeof selectGreedy>
-): ReturnType<typeof selectGreedy> {
- return selectGreedy(...args);
-}
-
-function selectGreedy(
- req: SelectPayCoinRequestNg,
- candidateDenoms: AvailableDenom[],
- wireFeesPerExchange: Record<string, AmountJson>,
- tally: CoinSelectionTally,
-): SelResult | undefined {
- const { wireFeeAmortization } = req;
- const selectedDenom: SelResult = {};
- for (const denom of candidateDenoms) {
- const contributions: AmountJson[] = [];
-
- // Don't use this coin if depositing it is more expensive than
- // the amount it would give the merchant.
- if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
- tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
- continue;
- }
-
- for (
- let i = 0;
- i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
- i++
- ) {
- tally = tallyFees(
- tally,
- wireFeesPerExchange,
- wireFeeAmortization,
- denom.exchangeBaseUrl,
- Amounts.parseOrThrow(denom.feeDeposit),
- );
-
- const coinSpend = Amounts.max(
- Amounts.min(tally.amountPayRemaining, denom.value),
- denom.feeDeposit,
- );
-
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- coinSpend,
- ).amount;
-
- contributions.push(coinSpend);
- }
-
- if (contributions.length) {
- const avKey = makeAvailabilityKey(
- denom.exchangeBaseUrl,
- denom.denomPubHash,
- denom.maxAge,
- );
- let sd = selectedDenom[avKey];
- if (!sd) {
- sd = {
- contributions: [],
- denomPubHash: denom.denomPubHash,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- maxAge: denom.maxAge,
- expireDeposit: denom.stampExpireDeposit,
- expireWithdraw: denom.stampExpireWithdraw,
- };
- }
- sd.contributions.push(...contributions);
- selectedDenom[avKey] = sd;
- }
- }
- return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
-}
-
-function selectForced(
- req: SelectPayCoinRequestNg,
- candidateDenoms: AvailableDenom[],
-): SelResult | undefined {
- const selectedDenom: SelResult = {};
-
- const forcedSelection = req.forcedSelection;
- checkLogicInvariant(!!forcedSelection);
-
- for (const forcedCoin of forcedSelection.coins) {
- let found = false;
- for (const aci of candidateDenoms) {
- if (aci.numAvailable <= 0) {
- continue;
- }
- if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
- aci.numAvailable--;
- const avKey = makeAvailabilityKey(
- aci.exchangeBaseUrl,
- aci.denomPubHash,
- aci.maxAge,
- );
- let sd = selectedDenom[avKey];
- if (!sd) {
- sd = {
- contributions: [],
- denomPubHash: aci.denomPubHash,
- exchangeBaseUrl: aci.exchangeBaseUrl,
- maxAge: aci.maxAge,
- expireDeposit: aci.stampExpireDeposit,
- expireWithdraw: aci.stampExpireWithdraw,
- };
- }
- sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
- selectedDenom[avKey] = sd;
- found = true;
- break;
- }
- }
- if (!found) {
- throw Error("can't find coin for forced coin selection");
- }
- }
-
- return selectedDenom;
-}
-
-export function checkAccountRestriction(
- paytoUri: string,
- restrictions: AccountRestriction[],
-): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
- for (const myRestriction of restrictions) {
- switch (myRestriction.type) {
- case "deny":
- return { ok: false };
- case "regex":
- const regex = new RegExp(myRestriction.payto_regex);
- if (!regex.test(paytoUri)) {
- return {
- ok: false,
- hint: myRestriction.human_hint,
- hintI18n: myRestriction.human_hint_i18n,
- };
- }
- }
- }
- return {
- ok: true,
- };
-}
-
-export interface SelectPayCoinRequestNg {
- exchanges: AllowedExchangeInfo[];
- auditors: AllowedAuditorInfo[];
- wireMethod: string;
- contractTermsAmount: AmountJson;
- depositFeeLimit: AmountJson;
- wireFeeLimit: AmountJson;
- wireFeeAmortization: number;
- prevPayCoins?: PreviousPayCoins;
- requiredMinimumAge?: number;
- forcedSelection?: ForcedCoinSel;
-
- /**
- * Deposit payto URI, in case we already know the account that
- * will be deposited into.
- *
- * That is typically the case when the wallet does a deposit to
- * return funds to the user's own bank account.
- */
- depositPaytoUri?: string;
-}
-
-export type AvailableDenom = DenominationInfo & {
- maxAge: number;
- numAvailable: number;
-};
-
-async function selectPayMerchantCandidates(
- ws: InternalWalletState,
- req: SelectPayCoinRequestNg,
-): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
- return await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadOnly(async (tx) => {
- // FIXME: Use the existing helper (from balance.ts) to
- // get acceptable exchanges.
- const denoms: AvailableDenom[] = [];
- const exchanges = await tx.exchanges.iter().toArray();
- const wfPerExchange: Record<string, AmountJson> = {};
- for (const exchange of exchanges) {
- const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
- // 1.- exchange has same currency
- if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
- continue;
- }
- let wireMethodFee: string | undefined;
- // 2.- exchange supports wire method
- for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- if (pp.targetType !== req.wireMethod) {
- continue;
- }
- const wireFeeStr = exchangeDetails.wireInfo.feesForType[
- req.wireMethod
- ]?.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
- let debitAccountCheckOk = false;
- if (req.depositPaytoUri) {
- // FIXME: We should somehow propagate the hint here!
- const checkResult = checkAccountRestriction(
- req.depositPaytoUri,
- acc.debit_restrictions,
- );
- if (checkResult.ok) {
- debitAccountCheckOk = true;
- }
- } else {
- debitAccountCheckOk = true;
- }
-
- if (wireFeeStr) {
- wireMethodFee = wireFeeStr;
- }
- break;
- }
- if (!wireMethodFee) {
- break;
- }
- wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee);
-
- // 3.- exchange is trusted in the exchange list or auditor list
- let accepted = false;
- for (const allowedExchange of req.exchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- accepted = true;
- break;
- }
- }
- for (const allowedAuditor of req.auditors) {
- for (const providedAuditor of exchangeDetails.auditors) {
- if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
- accepted = true;
- break;
- }
- }
- }
- if (!accepted) {
- continue;
- }
- // 4.- filter coins restricted by age
- let ageLower = 0;
- let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
- if (req.requiredMinimumAge) {
- ageLower = req.requiredMinimumAge;
- }
- const myExchangeCoins =
- await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
- GlobalIDB.KeyRange.bound(
- [exchangeDetails.exchangeBaseUrl, ageLower, 1],
- [
- exchangeDetails.exchangeBaseUrl,
- ageUpper,
- Number.MAX_SAFE_INTEGER,
- ],
- ),
- );
- // 5.- save denoms with how many coins are available
- // FIXME: Check that the individual denomination is audited!
- // FIXME: Should we exclude denominations that are
- // not spendable anymore?
- for (const coinAvail of myExchangeCoins) {
- const denom = await tx.denominations.get([
- coinAvail.exchangeBaseUrl,
- coinAvail.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- if (denom.isRevoked || !denom.isOffered) {
- continue;
- }
- denoms.push({
- ...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
- maxAge: coinAvail.maxAge,
- });
- }
- }
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- denoms.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.value, o2.value) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPubHash, o2.denomPubHash),
- );
- return [denoms, wfPerExchange];
- });
-}
-
-/**
- * Get a list of denominations (with repetitions possible)
- * whose total value is as close as possible to the available
- * amount, but never larger.
- */
-export function selectWithdrawalDenominations(
- amountAvailable: AmountJson,
- denoms: DenominationRecord[],
- denomselAllowLate: boolean = false,
-): DenomSelectionState {
- let remaining = Amounts.copy(amountAvailable);
-
- const selectedDenoms: {
- count: number;
- denomPubHash: string;
- }[] = [];
-
- let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
- let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
-
- denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
- denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
-
- for (const d of denoms) {
- const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount;
- const res = Amounts.divmod(remaining, cost);
- const count = res.quotient;
- remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount;
- if (count > 0) {
- totalCoinValue = Amounts.add(
- totalCoinValue,
- Amounts.mult(d.value, count).amount,
- ).amount;
- totalWithdrawCost = Amounts.add(
- totalWithdrawCost,
- Amounts.mult(cost, count).amount,
- ).amount;
- selectedDenoms.push({
- count,
- denomPubHash: d.denomPubHash,
- });
- }
-
- if (Amounts.isZero(remaining)) {
- break;
- }
- }
-
- if (logger.shouldLogTrace()) {
- logger.trace(
- `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
- );
- for (const sd of selectedDenoms) {
- logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`);
- }
- logger.trace("(end of withdrawal denom list)");
- }
-
- return {
- selectedDenoms,
- totalCoinValue: Amounts.stringify(totalCoinValue),
- totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
- };
-}
-
-export function selectForcedWithdrawalDenominations(
- amountAvailable: AmountJson,
- denoms: DenominationRecord[],
- forcedDenomSel: ForcedDenomSel,
- denomselAllowLate: boolean,
-): DenomSelectionState {
- const selectedDenoms: {
- count: number;
- denomPubHash: string;
- }[] = [];
-
- let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
- let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
-
- denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
- denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
-
- for (const fds of forcedDenomSel.denoms) {
- const count = fds.count;
- const denom = denoms.find((x) => {
- return Amounts.cmp(x.value, fds.value) == 0;
- });
- if (!denom) {
- throw Error(
- `unable to find denom for forced selection (value ${fds.value})`,
- );
- }
- const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount;
- totalCoinValue = Amounts.add(
- totalCoinValue,
- Amounts.mult(denom.value, count).amount,
- ).amount;
- totalWithdrawCost = Amounts.add(
- totalWithdrawCost,
- Amounts.mult(cost, count).amount,
- ).amount;
- selectedDenoms.push({
- count,
- denomPubHash: denom.denomPubHash,
- });
- }
-
- return {
- selectedDenoms,
- totalCoinValue: Amounts.stringify(totalCoinValue),
- totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
- };
-}
-
-export interface CoinInfo {
- id: string;
- value: AmountJson;
- denomDeposit: AmountJson;
- denomWithdraw: AmountJson;
- denomRefresh: AmountJson;
- totalAvailable: number | undefined;
- exchangeWire: AmountJson | undefined;
- exchangePurse: AmountJson | undefined;
- duration: Duration;
- exchangeBaseUrl: string;
- maxAge: number;
-}
-
-export interface SelectedPeerCoin {
- coinPub: string;
- coinPriv: string;
- contribution: AmountString;
- denomPubHash: string;
- denomSig: UnblindedSignature;
- ageCommitmentProof: AgeCommitmentProof | undefined;
-}
-
-export interface PeerCoinSelectionDetails {
- exchangeBaseUrl: string;
-
- /**
- * Info of Coins that were selected.
- */
- coins: SelectedPeerCoin[];
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- depositFees: AmountJson;
-
- maxExpirationDate: TalerProtocolTimestamp;
-}
-
-export type SelectPeerCoinsResult =
- | { type: "success"; result: PeerCoinSelectionDetails }
- | {
- type: "failure";
- insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
- };
-
-export interface PeerCoinRepair {
- exchangeBaseUrl: string;
- coinPubs: CoinPublicKeyString[];
- contribs: AmountJson[];
-}
-
-export interface PeerCoinSelectionRequest {
- instructedAmount: AmountJson;
-
- /**
- * Instruct the coin selection to repair this coin
- * selection instead of selecting completely new coins.
- */
- repair?: PeerCoinRepair;
-}
-
-/**
- * Get coin availability information for a certain exchange.
- */
-async function selectPayPeerCandidatesForExchange(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<"coinAvailability" | "denominations">,
- exchangeBaseUrl: string,
-): Promise<AvailableDenom[]> {
- const denoms: AvailableDenom[] = [];
-
- let ageLower = 0;
- let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
- const myExchangeCoins =
- await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
- GlobalIDB.KeyRange.bound(
- [exchangeBaseUrl, ageLower, 1],
- [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
- ),
- );
-
- for (const coinAvail of myExchangeCoins) {
- const denom = await tx.denominations.get([
- coinAvail.exchangeBaseUrl,
- coinAvail.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- if (denom.isRevoked || !denom.isOffered) {
- continue;
- }
- denoms.push({
- ...DenominationRecord.toDenomInfo(denom),
- numAvailable: coinAvail.freshCoinCount ?? 0,
- maxAge: coinAvail.maxAge,
- });
- }
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- denoms.sort(
- (o1, o2) =>
- -Amounts.cmp(o1.value, o2.value) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPubHash, o2.denomPubHash),
- );
-
- return denoms;
-}
-
-interface PeerCoinSelectionTally {
- amountAcc: AmountJson;
- depositFeesAcc: AmountJson;
- lastDepositFee: AmountJson;
-}
-
-/**
- * exporting for testing
- */
-export function testing_greedySelectPeer(
- ...args: Parameters<typeof greedySelectPeer>
-): ReturnType<typeof greedySelectPeer> {
- return greedySelectPeer(...args);
-}
-
-function greedySelectPeer(
- candidates: AvailableDenom[],
- instructedAmount: AmountLike,
- tally: PeerCoinSelectionTally,
-): SelResult | undefined {
- const selectedDenom: SelResult = {};
- for (const denom of candidates) {
- const contributions: AmountJson[] = [];
- for (
- let i = 0;
- i < denom.numAvailable &&
- Amounts.cmp(tally.amountAcc, instructedAmount) < 0;
- i++
- ) {
- const amountPayRemaining = Amounts.sub(
- instructedAmount,
- tally.amountAcc,
- ).amount;
- // Maximum amount the coin could effectively contribute.
- const maxCoinContrib = Amounts.sub(denom.value, denom.feeDeposit).amount;
-
- const coinSpend = Amounts.min(
- Amounts.add(amountPayRemaining, denom.feeDeposit).amount,
- maxCoinContrib,
- );
-
- tally.amountAcc = Amounts.add(tally.amountAcc, coinSpend).amount;
- tally.amountAcc = Amounts.sub(tally.amountAcc, denom.feeDeposit).amount;
-
- tally.depositFeesAcc = Amounts.add(
- tally.depositFeesAcc,
- denom.feeDeposit,
- ).amount;
-
- tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
-
- contributions.push(coinSpend);
- }
- if (contributions.length > 0) {
- const avKey = makeAvailabilityKey(
- denom.exchangeBaseUrl,
- denom.denomPubHash,
- denom.maxAge,
- );
- let sd = selectedDenom[avKey];
- if (!sd) {
- sd = {
- contributions: [],
- denomPubHash: denom.denomPubHash,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- maxAge: denom.maxAge,
- expireDeposit: denom.stampExpireDeposit,
- expireWithdraw: denom.stampExpireWithdraw,
- };
- }
- sd.contributions.push(...contributions);
- selectedDenom[avKey] = sd;
- }
- if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) {
- break;
- }
- }
-
- if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) {
- return selectedDenom;
- }
- return undefined;
-}
-
-export async function selectPeerCoins(
- ws: InternalWalletState,
- req: PeerCoinSelectionRequest,
-): Promise<SelectPeerCoinsResult> {
- const instructedAmount = req.instructedAmount;
- if (Amounts.isZero(instructedAmount)) {
- // Other parts of the code assume that we have at least
- // one coin to spend.
- throw new Error("amount of zero not allowed");
- }
- return await ws.db
- .mktx((x) => [
- x.exchanges,
- x.contractTerms,
- x.coins,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- x.peerPushDebit,
- ])
- .runReadWrite(async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- const exchangeFeeGap: { [url: string]: AmountJson } = {};
- const currency = Amounts.currencyOf(instructedAmount);
- for (const exch of exchanges) {
- if (exch.detailsPointer?.currency !== currency) {
- continue;
- }
- const candidates = await selectPayPeerCandidatesForExchange(
- ws,
- tx,
- exch.baseUrl,
- );
- const tally: PeerCoinSelectionTally = {
- amountAcc: Amounts.zeroOfCurrency(currency),
- depositFeesAcc: Amounts.zeroOfCurrency(currency),
- lastDepositFee: Amounts.zeroOfCurrency(currency),
- };
- const resCoins: {
- coinPub: string;
- coinPriv: string;
- contribution: AmountString;
- denomPubHash: string;
- denomSig: UnblindedSignature;
- ageCommitmentProof: AgeCommitmentProof | undefined;
- }[] = [];
-
- if (req.repair && req.repair.exchangeBaseUrl === exch.baseUrl) {
- for (let i = 0; i < req.repair.coinPubs.length; i++) {
- const contrib = req.repair.contribs[i];
- const coin = await tx.coins.get(req.repair.coinPubs[i]);
- if (!coin) {
- throw Error("repair not possible, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- resCoins.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contribution: Amounts.stringify(contrib),
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- });
- const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
- tally.lastDepositFee = depositFee;
- tally.amountAcc = Amounts.add(
- tally.amountAcc,
- Amounts.sub(contrib, depositFee).amount,
- ).amount;
- tally.depositFeesAcc = Amounts.add(
- tally.depositFeesAcc,
- depositFee,
- ).amount;
- }
- }
-
- const selectedDenom = greedySelectPeer(
- candidates,
- instructedAmount,
- tally,
- );
-
- if (selectedDenom) {
- let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
- for (const dph of Object.keys(selectedDenom)) {
- const selInfo = selectedDenom[dph];
- // Compute earliest time that a selected denom
- // would have its coins auto-refreshed.
- minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
- minAutorefreshExecuteThreshold,
- AbsoluteTime.toProtocolTimestamp(
- getAutoRefreshExecuteThreshold({
- stampExpireDeposit: selInfo.expireDeposit,
- stampExpireWithdraw: selInfo.expireWithdraw,
- }),
- ),
- );
- const numRequested = selInfo.contributions.length;
- const query = [
- selInfo.exchangeBaseUrl,
- selInfo.denomPubHash,
- selInfo.maxAge,
- CoinStatus.Fresh,
- ];
- logger.info(`query: ${j2s(query)}`);
- const coins =
- await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
- query,
- numRequested,
- );
- if (coins.length != numRequested) {
- throw Error(
- `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
- );
- }
- for (let i = 0; i < selInfo.contributions.length; i++) {
- resCoins.push({
- coinPriv: coins[i].coinPriv,
- coinPub: coins[i].coinPub,
- contribution: Amounts.stringify(selInfo.contributions[i]),
- ageCommitmentProof: coins[i].ageCommitmentProof,
- denomPubHash: selInfo.denomPubHash,
- denomSig: coins[i].denomSig,
- });
- }
- }
-
- const res: PeerCoinSelectionDetails = {
- exchangeBaseUrl: exch.baseUrl,
- coins: resCoins,
- depositFees: tally.depositFeesAcc,
- maxExpirationDate: minAutorefreshExecuteThreshold,
- };
- return { type: "success", result: res };
- }
-
- const diff = Amounts.sub(instructedAmount, tally.amountAcc).amount;
- exchangeFeeGap[exch.baseUrl] = Amounts.add(
- tally.lastDepositFee,
- diff,
- ).amount;
-
- continue;
- }
-
- // We were unable to select coins.
- // Now we need to produce error details.
-
- const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
- currency,
- });
-
- const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
-
- let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
-
- for (const exch of exchanges) {
- if (exch.detailsPointer?.currency !== currency) {
- continue;
- }
- const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
- currency,
- restrictExchangeTo: exch.baseUrl,
- });
- let gap =
- exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
- if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
- // Show fee gap only if we should've been able to pay with the material amount
- gap = Amounts.zeroOfCurrency(currency);
- }
- perExchange[exch.baseUrl] = {
- balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
- feeGapEstimate: Amounts.stringify(gap),
- };
-
- maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
- }
-
- const errDetails: PayPeerInsufficientBalanceDetails = {
- amountRequested: Amounts.stringify(instructedAmount),
- balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
- balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
- feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
- perExchange,
- };
-
- return { type: "failure", insufficientBalanceDetails: errDetails };
- });
-}
diff --git a/packages/taler-wallet-core/src/util/invariants.ts b/packages/taler-wallet-core/src/util/invariants.ts
deleted file mode 100644
index 3598d857c..000000000
--- a/packages/taler-wallet-core/src/util/invariants.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-export class InvariantViolatedError extends Error {
- constructor(message?: string) {
- super(message);
- Object.setPrototypeOf(this, InvariantViolatedError.prototype);
- }
-}
-
-/**
- * Helpers for invariants.
- */
-
-export function checkDbInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: database invariant failed (${m})`);
- } else {
- throw Error("BUG: database invariant failed");
- }
- }
-}
-
-export function checkLogicInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: logic invariant failed (${m})`);
- } else {
- throw Error("BUG: logic invariant failed");
- }
- }
-}
diff --git a/packages/taler-wallet-core/src/util/promiseUtils.ts b/packages/taler-wallet-core/src/util/promiseUtils.ts
deleted file mode 100644
index 23f1c06a5..000000000
--- a/packages/taler-wallet-core/src/util/promiseUtils.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- 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.
-
- 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
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-export interface OpenedPromise<T> {
- promise: Promise<T>;
- resolve: (val: T) => void;
- reject: (err: any) => void;
-}
-
-/**
- * Get an unresolved promise together with its extracted resolve / reject
- * function.
- *
- * Recent ECMAScript proposals also call this a promise capability.
- */
-export function openPromise<T>(): OpenedPromise<T> {
- let resolve: ((x?: any) => void) | null = null;
- let reject: ((reason?: any) => void) | null = null;
- const promise = new Promise<T>((res, rej) => {
- resolve = res;
- reject = rej;
- });
- if (!(resolve && reject)) {
- // Never happens, unless JS implementation is broken
- throw Error();
- }
- return { resolve, reject, promise };
-}
-
-export class AsyncCondition {
- private promCap?: OpenedPromise<void> = undefined;
- constructor() {}
-
- wait(): Promise<void> {
- if (!this.promCap) {
- this.promCap = openPromise<void>();
- }
- return this.promCap.promise;
- }
-
- trigger(): void {
- if (this.promCap) {
- this.promCap.resolve();
- }
- this.promCap = undefined;
- }
-}
diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts
deleted file mode 100644
index d198e03c9..000000000
--- a/packages/taler-wallet-core/src/util/timer.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2017-2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Cross-platform timers.
- *
- * NodeJS and the browser use slightly different timer API,
- * this abstracts over these differences.
- */
-
-/**
- * Imports.
- */
-import { Logger, Duration } from "@gnu-taler/taler-util";
-
-const logger = new Logger("timer.ts");
-
-/**
- * Cancelable timer.
- */
-export interface TimerHandle {
- clear(): void;
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void;
-}
-
-class IntervalHandle {
- constructor(public h: any) {}
-
- clear(): void {
- clearInterval(this.h);
- }
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void {
- if (typeof this.h === "object" && "unref" in this.h) {
- this.h.unref();
- }
- }
-}
-
-class TimeoutHandle {
- constructor(public h: any) {}
-
- clear(): void {
- clearTimeout(this.h);
- }
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void {
- if (typeof this.h === "object" && "unref" in this.h) {
- this.h.unref();
- }
- }
-}
-
-/**
- * Get a performance counter in nanoseconds.
- */
-export const performanceNow: () => bigint = (() => {
- // @ts-ignore
- if (typeof process !== "undefined" && process.hrtime) {
- return () => {
- return process.hrtime.bigint();
- };
- }
-
- // @ts-ignore
- if (typeof performance !== "undefined") {
- // @ts-ignore
- return () => BigInt(Math.floor(performance.now() * 1000)) * BigInt(1000);
- }
-
- return () => BigInt(0);
-})();
-
-const nullTimerHandle = {
- clear() {
- // do nothing
- return;
- },
- unref() {
- // do nothing
- return;
- },
-};
-
-/**
- * Group of timers that can be destroyed at once.
- */
-export interface TimerAPI {
- after(delayMs: number, callback: () => void): TimerHandle;
- every(delayMs: number, callback: () => void): TimerHandle;
-}
-
-export class SetTimeoutTimerAPI implements TimerAPI {
- /**
- * Call a function every time the delay given in milliseconds passes.
- */
- every(delayMs: number, callback: () => void): TimerHandle {
- return new IntervalHandle(setInterval(callback, delayMs));
- }
-
- /**
- * Call a function after the delay given in milliseconds passes.
- */
- after(delayMs: number, callback: () => void): TimerHandle {
- return new TimeoutHandle(setTimeout(callback, delayMs));
- }
-}
-
-export const timer = new SetTimeoutTimerAPI();
-
-/**
- * Implementation of [[TimerGroup]] using setTimeout
- */
-export class TimerGroup {
- private stopped = false;
-
- private readonly timerMap: { [index: number]: TimerHandle } = {};
-
- private idGen = 1;
-
- constructor(public readonly timerApi: TimerAPI) {}
-
- stopCurrentAndFutureTimers(): void {
- this.stopped = true;
- for (const x in this.timerMap) {
- if (!this.timerMap.hasOwnProperty(x)) {
- continue;
- }
- this.timerMap[x].clear();
- delete this.timerMap[x];
- }
- }
-
- resolveAfter(delayMs: Duration): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- if (delayMs.d_ms !== "forever") {
- this.after(delayMs.d_ms, () => {
- resolve();
- });
- }
- });
- }
-
- after(delayMs: number, callback: () => void): TimerHandle {
- if (this.stopped) {
- logger.warn("dropping timer since timer group is stopped");
- return nullTimerHandle;
- }
- const h = this.timerApi.after(delayMs, callback);
- const myId = this.idGen++;
- this.timerMap[myId] = h;
-
- const tm = this.timerMap;
-
- return {
- clear() {
- h.clear();
- delete tm[myId];
- },
- unref() {
- h.unref();
- },
- };
- }
-
- every(delayMs: number, callback: () => void): TimerHandle {
- if (this.stopped) {
- logger.warn("dropping timer since timer group is stopped");
- return nullTimerHandle;
- }
- const h = this.timerApi.every(delayMs, callback);
- const myId = this.idGen++;
- this.timerMap[myId] = h;
-
- const tm = this.timerMap;
-
- return {
- clear() {
- h.clear();
- delete tm[myId];
- },
- unref() {
- h.unref();
- },
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts
index 023cbb1ff..d33a23cdd 100644
--- a/packages/taler-wallet-core/src/versions.ts
+++ b/packages/taler-wallet-core/src/versions.ts
@@ -50,11 +50,9 @@ export const WALLET_COREBANK_API_PROTOCOL_VERSION = "2:0:0";
export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0";
/**
- * Semver of the wallet-core API implementation.
- * Will be replaced with the value from package.json in a
- * post-compilation step (inside lib/).
+ * Libtool version of the wallet-core API.
*/
-export const WALLET_CORE_API_IMPLEMENTATION_VERSION = "3:0:2";
+export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0";
/**
* Libtool rules:
@@ -68,3 +66,19 @@ export const WALLET_CORE_API_IMPLEMENTATION_VERSION = "3:0:2";
* If any interfaces have been removed or changed since the last public
* release, then set age to 0.
*/
+
+// Provided either by bundler or in the next lines.
+declare global {
+ const walletCoreBuildInfo: {
+ implementationSemver: string;
+ implementationGitHash: string;
+ };
+}
+
+// Provide walletCoreBuildInfo if the bundler does not override it.
+if (!("walletCoreBuildInfo" in globalThis)) {
+ (globalThis as any).walletCoreBuildInfo = {
+ implementationSemver: "unknown",
+ implementationGitHash: "unknown",
+ } satisfies typeof walletCoreBuildInfo;
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index a4be0f448..1bcab801c 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -29,15 +29,19 @@ import {
AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest,
AcceptManualWithdrawalResult,
- AcceptRewardRequest,
- AcceptTipResponse,
AcceptWithdrawalResponse,
AddExchangeRequest,
+ AddGlobalCurrencyAuditorRequest,
+ AddGlobalCurrencyExchangeRequest,
AddKnownBankAccountsRequest,
AmountResponse,
ApplyDevExperimentRequest,
BackupRecovery,
BalancesResponse,
+ CanonicalizeBaseUrlRequest,
+ CanonicalizeBaseUrlResponse,
+ CheckPayTemplateReponse,
+ CheckPayTemplateRequest,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
CheckPeerPushDebitRequest,
@@ -47,10 +51,12 @@ import {
ConfirmPayResult,
ConfirmPeerPullDebitRequest,
ConfirmPeerPushCreditRequest,
+ ConfirmWithdrawalRequest,
ConvertAmountRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
CreateStoredBackupResponse,
+ DeleteExchangeRequest,
DeleteStoredBackupRequest,
DeleteTransactionRequest,
ExchangeDetailedResponse,
@@ -59,6 +65,7 @@ import {
FailTransactionRequest,
ForceRefreshRequest,
ForgetKnownBankAccountsRequest,
+ GetActiveTasksResponse,
GetAmountRequest,
GetBalanceDetailRequest,
GetContractTermsDetailsRequest,
@@ -66,12 +73,16 @@ import {
GetCurrencySpecificationResponse,
GetExchangeEntryByUrlRequest,
GetExchangeEntryByUrlResponse,
+ GetExchangeResourcesRequest,
+ GetExchangeResourcesResponse,
GetExchangeTosRequest,
GetExchangeTosResult,
GetPlanForOperationRequest,
GetPlanForOperationResponse,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
+ HintNetworkAvailabilityRequest,
+ ImportDbRequest,
InitRequest,
InitResponse,
InitiatePeerPullCreditRequest,
@@ -80,9 +91,14 @@ import {
InitiatePeerPushDebitResponse,
IntegrationTestArgs,
KnownBankAccounts,
+ ListAssociatedRefreshesRequest,
+ ListAssociatedRefreshesResponse,
ListExchangesForScopedCurrencyRequest,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
ListKnownBankAccountsRequest,
- WithdrawalDetailsForAmount,
+ PrepareBankIntegratedWithdrawalRequest,
+ PrepareBankIntegratedWithdrawalResponse,
PrepareDepositRequest,
PrepareDepositResponse,
PreparePayRequest,
@@ -93,12 +109,12 @@ import {
PreparePeerPushCreditRequest,
PreparePeerPushCreditResponse,
PrepareRefundRequest,
- PrepareRewardRequest,
- PrepareTipResult as PrepareRewardResult,
PrepareWithdrawExchangeRequest,
PrepareWithdrawExchangeResponse,
RecoverStoredBackupRequest,
RecoveryLoadRequest,
+ RemoveGlobalCurrencyAuditorRequest,
+ RemoveGlobalCurrencyExchangeRequest,
RetryTransactionRequest,
SetCoinSuspendedRequest,
SetWalletDeviceIdRequest,
@@ -107,12 +123,18 @@ import {
StartRefundQueryForUriResponse,
StartRefundQueryRequest,
StoredBackupList,
+ TalerMerchantApi,
TestPayArgs,
TestPayResult,
+ TestingGetDenomStatsRequest,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionRequest,
+ TestingListTasksForTransactionsResponse,
TestingSetTimetravelRequest,
TestingWaitTransactionRequest,
Transaction,
TransactionByIdRequest,
+ TransactionWithdrawal,
TransactionsRequest,
TransactionsResponse,
TxIdResponse,
@@ -125,9 +147,10 @@ import {
ValidateIbanResponse,
WalletContractData,
WalletCoreVersion,
- WalletCurrencyInfo,
WithdrawTestBalanceRequest,
WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalTransactionByURIRequest,
} from "@gnu-taler/taler-util";
import {
AddBackupProviderRequest,
@@ -135,16 +158,17 @@ import {
BackupInfo,
RemoveBackupProviderRequest,
RunBackupCycleRequest,
-} from "./operations/backup/index.js";
-import { MerchantPaymentBalanceDetails } from "./operations/balance.js";
-import { PendingOperationsResponse as PendingTasksResponse } from "./pending-types.js";
+} from "./backup/index.js";
+import { PaymentBalanceDetails } from "./balance.js";
export enum WalletApiOperation {
InitWallet = "initWallet",
+ SetWalletRunConfig = "setWalletRunConfig",
WithdrawTestkudos = "withdrawTestkudos",
WithdrawTestBalance = "withdrawTestBalance",
PreparePayForUri = "preparePayForUri",
SharePayment = "sharePayment",
+ CheckPayForTemplate = "checkPayForTemplate",
PreparePayForTemplate = "preparePayForTemplate",
GetContractTermsDetails = "getContractTermsDetails",
RunIntegrationTest = "runIntegrationTest",
@@ -154,6 +178,7 @@ export enum WalletApiOperation {
AddExchange = "addExchange",
GetTransactions = "getTransactions",
GetTransactionById = "getTransactionById",
+ GetWithdrawalTransactionByUri = "getWithdrawalTransactionByUri",
TestingGetSampleTransactions = "testingGetSampleTransactions",
ListExchanges = "listExchanges",
GetExchangeEntryByUrl = "getExchangeEntryByUrl",
@@ -175,9 +200,13 @@ export enum WalletApiOperation {
GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
GetPendingOperations = "getPendingOperations",
+ GetActiveTasks = "getActiveTasks",
SetExchangeTosAccepted = "setExchangeTosAccepted",
+ SetExchangeTosForgotten = "SetExchangeTosForgotten",
StartRefundQueryForUri = "startRefundQueryForUri",
StartRefundQuery = "startRefundQuery",
+ PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal",
+ ConfirmWithdrawal = "confirmWithdrawal",
AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal",
GetExchangeTos = "getExchangeTos",
GetExchangeDetailedInfo = "getExchangeDetailedInfo",
@@ -192,8 +221,6 @@ export enum WalletApiOperation {
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
ForceRefresh = "forceRefresh",
- PrepareReward = "prepareReward",
- AcceptReward = "acceptReward",
ExportBackup = "exportBackup",
AddBackupProvider = "addBackupProvider",
RemoveBackupProvider = "removeBackupProvider",
@@ -203,7 +230,6 @@ export enum WalletApiOperation {
GetBackupInfo = "getBackupInfo",
PrepareDeposit = "prepareDeposit",
GetVersion = "getVersion",
- ListCurrencies = "listCurrencies",
GenerateDepositGroupTxId = "generateDepositGroupTxId",
CreateDepositGroup = "createDepositGroup",
SetWalletDeviceId = "setWalletDeviceId",
@@ -221,28 +247,46 @@ export enum WalletApiOperation {
Recycle = "recycle",
ApplyDevExperiment = "applyDevExperiment",
ValidateIban = "validateIban",
- TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
- TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
- TestingWaitTransactionState = "testingWaitTransactionState",
- TestingSetTimetravel = "testingSetTimetravel",
GetCurrencySpecification = "getCurrencySpecification",
ListStoredBackups = "listStoredBackups",
CreateStoredBackup = "createStoredBackup",
DeleteStoredBackup = "deleteStoredBackup",
RecoverStoredBackup = "recoverStoredBackup",
UpdateExchangeEntry = "updateExchangeEntry",
- TestingWaitTasksProcessed = "testingWaitTasksProcessed",
ListExchangesForScopedCurrency = "listExchangesForScopedCurrency",
PrepareWithdrawExchange = "prepareWithdrawExchange",
+ GetExchangeResources = "getExchangeResources",
+ DeleteExchange = "deleteExchange",
+ ListGlobalCurrencyExchanges = "listGlobalCurrencyExchanges",
+ ListGlobalCurrencyAuditors = "listGlobalCurrencyAuditors",
+ AddGlobalCurrencyExchange = "addGlobalCurrencyExchange",
+ RemoveGlobalCurrencyExchange = "removeGlobalCurrencyExchange",
+ AddGlobalCurrencyAuditor = "addGlobalCurrencyAuditor",
+ RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor",
+ ListAssociatedRefreshes = "listAssociatedRefreshes",
+ Shutdown = "shutdown",
+ HintNetworkAvailability = "hintNetworkAvailability",
+ CanonicalizeBaseUrl = "canonicalizeBaseUrl",
+ TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
+ TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
+ TestingWaitTransactionState = "testingWaitTransactionState",
+ TestingWaitTasksDone = "testingWaitTasksDone",
+ TestingSetTimetravel = "testingSetTimetravel",
+ TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop",
+ TestingListTaskForTransaction = "testingListTasksForTransaction",
+ TestingGetDenomStats = "testingGetDenomStats",
+ TestingPing = "testingPing",
+ TestingGetReserveHistory = "testingGetReserveHistory",
}
// group: Initialization
type EmptyObject = Record<string, never>;
+
/**
* Initialize wallet-core.
*
- * Must be the request before any other operations.
+ * Must be the first request made to wallet-core.
*/
export type InitWalletOp = {
op: WalletApiOperation.InitWallet;
@@ -250,57 +294,34 @@ export type InitWalletOp = {
response: InitResponse;
};
-export type GetVersionOp = {
- op: WalletApiOperation.GetVersion;
+export type ShutdownOp = {
+ op: WalletApiOperation.Shutdown;
request: EmptyObject;
- response: WalletCoreVersion;
+ response: EmptyObject;
};
/**
- * Configurations options for the Wallet
+ * Change the configuration of wallet-core.
*
- * All missing values of the config will be replaced with default values
- * Default values are defined by Wallet.getDefaultConfig()
+ * Currently an alias for the initWallet request.
*/
-export type WalletConfigParameter = RecursivePartial<WalletConfig>;
+export type SetWalletRunConfigOp = {
+ op: WalletApiOperation.SetWalletRunConfig;
+ request: InitRequest;
+ response: InitResponse;
+};
-export interface BuiltinExchange {
- exchangeBaseUrl: string;
- currencyHint?: string;
-}
+export type GetVersionOp = {
+ op: WalletApiOperation.GetVersion;
+ request: EmptyObject;
+ response: WalletCoreVersion;
+};
-export interface WalletConfig {
- /**
- * Initialization values useful for a complete startup.
- *
- * These are values may be overridden by different wallets
- */
- builtin: {
- exchanges: BuiltinExchange[];
- };
-
- /**
- * Unsafe options which it should only be used to create
- * testing environment.
- */
- testing: {
- /**
- * Allow withdrawal of denominations even though they are about to expire.
- */
- denomselAllowLate: boolean;
- devModeActive: boolean;
- insecureTrustExchange: boolean;
- preventThrottling: boolean;
- skipDefaults: boolean;
- };
-
- /**
- * Configurations values that may be safe to show to the user
- */
- features: {
- allowHttp: boolean;
- };
-}
+export type HintNetworkAvailabilityOp = {
+ op: WalletApiOperation.HintNetworkAvailability;
+ request: HintNetworkAvailabilityRequest;
+ response: EmptyObject;
+};
// group: Basic Wallet Information
@@ -315,7 +336,7 @@ export type GetBalancesOp = {
export type GetBalancesDetailOp = {
op: WalletApiOperation.GetBalanceDetail;
request: GetBalanceDetailRequest;
- response: MerchantPaymentBalanceDetails;
+ response: PaymentBalanceDetails;
};
export type GetPlanForOperationOp = {
@@ -362,6 +383,15 @@ export type GetTransactionsOp = {
};
/**
+ * List refresh transactions associated with another transaction.
+ */
+export type ListAssociatedRefreshesOp = {
+ op: WalletApiOperation.ListAssociatedRefreshes;
+ request: ListAssociatedRefreshesRequest;
+ response: ListAssociatedRefreshesResponse;
+};
+
+/**
* Get sample transactions.
*/
export type TestingGetSampleTransactionsOp = {
@@ -376,6 +406,12 @@ export type GetTransactionByIdOp = {
response: Transaction;
};
+export type GetWithdrawalTransactionByUriOp = {
+ op: WalletApiOperation.GetWithdrawalTransactionByUri;
+ request: WithdrawalTransactionByURIRequest;
+ response: TransactionWithdrawal | undefined;
+};
+
export type RetryPendingNowOp = {
op: WalletApiOperation.RetryPendingNow;
request: EmptyObject;
@@ -461,7 +497,27 @@ export type GetWithdrawalDetailsForUriOp = {
};
/**
+ * Prepare a bank-integrated withdrawal operation.
+ */
+export type PrepareBankIntegratedWithdrawalOp = {
+ op: WalletApiOperation.PrepareBankIntegratedWithdrawal;
+ request: PrepareBankIntegratedWithdrawalRequest;
+ response: PrepareBankIntegratedWithdrawalResponse;
+};
+
+/**
+ * Confirm a withdrawal transaction.
+ */
+export type ConfirmWithdrawalOp = {
+ op: WalletApiOperation.ConfirmWithdrawal;
+ request: ConfirmWithdrawalRequest;
+ response: EmptyObject;
+};
+
+/**
* Accept a bank-integrated withdrawal.
+ *
+ * @deprecated in favor of prepare/confirm withdrawal.
*/
export type AcceptBankIntegratedWithdrawalOp = {
op: WalletApiOperation.AcceptBankIntegratedWithdrawal;
@@ -495,6 +551,12 @@ export type SharePaymentOp = {
response: SharePaymentResult;
};
+export type CheckPayForTemplateOp = {
+ op: WalletApiOperation.CheckPayForTemplate;
+ request: CheckPayTemplateRequest;
+ response: CheckPayTemplateReponse;
+};
+
/**
* Prepare to make a payment based on a taler://pay-template/ URI.
*/
@@ -535,24 +597,42 @@ export type StartRefundQueryOp = {
response: EmptyObject;
};
-// group: Rewards
+// group: Global Currency management
-/**
- * Query and store information about a reward.
- */
-export type PrepareTipOp = {
- op: WalletApiOperation.PrepareReward;
- request: PrepareRewardRequest;
- response: PrepareRewardResult;
+export type ListGlobalCurrencyAuditorsOp = {
+ op: WalletApiOperation.ListGlobalCurrencyAuditors;
+ request: EmptyObject;
+ response: ListGlobalCurrencyAuditorsResponse;
};
-/**
- * Accept a reward.
- */
-export type AcceptTipOp = {
- op: WalletApiOperation.AcceptReward;
- request: AcceptRewardRequest;
- response: AcceptTipResponse;
+export type ListGlobalCurrencyExchangesOp = {
+ op: WalletApiOperation.ListGlobalCurrencyExchanges;
+ request: EmptyObject;
+ response: ListGlobalCurrencyExchangesResponse;
+};
+
+export type AddGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.AddGlobalCurrencyExchange;
+ request: AddGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type AddGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.AddGlobalCurrencyAuditor;
+ request: AddGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyExchange;
+ request: RemoveGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyAuditor;
+ request: RemoveGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
};
// group: Exchange Management
@@ -631,6 +711,15 @@ export type SetExchangeTosAcceptedOp = {
};
/**
+ * Accept a particular version of the exchange terms of service.
+ */
+export type SetExchangeTosForgottenOp = {
+ op: WalletApiOperation.SetExchangeTosForgotten;
+ request: AcceptExchangeTosRequest;
+ response: EmptyObject;
+};
+
+/**
* Get the current terms of a service of an exchange.
*/
export type GetExchangeTosOp = {
@@ -658,12 +747,21 @@ export type GetExchangeEntryByUrlOp = {
};
/**
- * List currencies known to the wallet.
+ * Get resources associated with an exchange.
*/
-export type ListCurrenciesOp = {
- op: WalletApiOperation.ListCurrencies;
- request: EmptyObject;
- response: WalletCurrencyInfo;
+export type GetExchangeResourcesOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: GetExchangeResourcesRequest;
+ response: GetExchangeResourcesResponse;
+};
+
+/**
+ * Get resources associated with an exchange.
+ */
+export type DeleteExchangeOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: DeleteExchangeRequest;
+ response: EmptyObject;
};
export type GetCurrencySpecificationOp = {
@@ -882,6 +980,12 @@ export type ValidateIbanOp = {
response: ValidateIbanResponse;
};
+export type CanonicalizeBaseUrlOp = {
+ op: WalletApiOperation.CanonicalizeBaseUrl;
+ request: CanonicalizeBaseUrlRequest;
+ response: CanonicalizeBaseUrlResponse;
+};
+
// group: Database Management
/**
@@ -895,8 +999,8 @@ export type ExportDbOp = {
export type ImportDbOp = {
op: WalletApiOperation.ImportDb;
- request: any;
- response: any;
+ request: ImportDbRequest;
+ response: EmptyObject;
};
/**
@@ -1018,11 +1122,19 @@ export type GetUserAttentionsUnreadCount = {
/**
* Get wallet-internal pending tasks.
+ *
+ * @deprecated
*/
export type GetPendingTasksOp = {
op: WalletApiOperation.GetPendingOperations;
request: EmptyObject;
- response: PendingTasksResponse;
+ response: any;
+};
+
+export type GetActiveTasksOp = {
+ op: WalletApiOperation.GetActiveTasks;
+ request: EmptyObject;
+ response: GetActiveTasksResponse;
};
/**
@@ -1044,6 +1156,15 @@ export type TestingSetTimetravelOp = {
};
/**
+ * Add an offset to the wallet's internal time.
+ */
+export type TestingListTasksForTransactionOp = {
+ op: WalletApiOperation.TestingListTaskForTransaction;
+ request: TestingListTasksForTransactionRequest;
+ response: TestingListTasksForTransactionsResponse;
+};
+
+/**
* Wait until all transactions are in a final state.
*/
export type TestingWaitTransactionsFinalOp = {
@@ -1053,19 +1174,19 @@ export type TestingWaitTransactionsFinalOp = {
};
/**
- * Wait until all refresh transactions are in a final state.
+ * Wait until all transactions are in a final state.
*/
-export type TestingWaitRefreshesFinalOp = {
- op: WalletApiOperation.TestingWaitRefreshesFinal;
+export type TestingWaitTasksDoneOp = {
+ op: WalletApiOperation.TestingWaitTasksDone;
request: EmptyObject;
response: EmptyObject;
};
/**
- * Wait until all tasks have been processed and the wallet is idle.
+ * Wait until all refresh transactions are in a final state.
*/
-export type TestingWaitTasksProcessedOp = {
- op: WalletApiOperation.TestingWaitTasksProcessed;
+export type TestingWaitRefreshesFinalOp = {
+ op: WalletApiOperation.TestingWaitRefreshesFinal;
request: EmptyObject;
response: EmptyObject;
};
@@ -1079,6 +1200,27 @@ export type TestingWaitTransactionStateOp = {
response: EmptyObject;
};
+export type TestingPingOp = {
+ op: WalletApiOperation.TestingPing;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+export type TestingGetReserveHistoryOp = {
+ op: WalletApiOperation.TestingGetReserveHistory;
+ request: EmptyObject;
+ response: any;
+};
+
+/**
+ * Get stats about an exchange denomination.
+ */
+export type TestingGetDenomStatsOp = {
+ op: WalletApiOperation.TestingGetDenomStats;
+ request: TestingGetDenomStatsRequest;
+ response: TestingGetDenomStatsResponse;
+};
+
/**
* Set a coin as (un-)suspended.
* Suspended coins won't be used for payments.
@@ -1101,9 +1243,11 @@ export type ForceRefreshOp = {
export type WalletOperations = {
[WalletApiOperation.InitWallet]: InitWalletOp;
+ [WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp;
[WalletApiOperation.GetVersion]: GetVersionOp;
[WalletApiOperation.PreparePayForUri]: PreparePayForUriOp;
[WalletApiOperation.SharePayment]: SharePaymentOp;
+ [WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp;
[WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
[WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
@@ -1123,8 +1267,10 @@ export type WalletOperations = {
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
[WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
[WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
+ [WalletApiOperation.GetWithdrawalTransactionByUri]: GetWithdrawalTransactionByUriOp;
[WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
[WalletApiOperation.GetPendingOperations]: GetPendingTasksOp;
+ [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp;
[WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests;
[WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount;
[WalletApiOperation.MarkAttentionRequestAsRead]: MarkAttentionRequestAsRead;
@@ -1133,11 +1279,8 @@ export type WalletOperations = {
[WalletApiOperation.ForceRefresh]: ForceRefreshOp;
[WalletApiOperation.DeleteTransaction]: DeleteTransactionOp;
[WalletApiOperation.RetryTransaction]: RetryTransactionOp;
- [WalletApiOperation.PrepareReward]: PrepareTipOp;
- [WalletApiOperation.AcceptReward]: AcceptTipOp;
[WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp;
[WalletApiOperation.StartRefundQuery]: StartRefundQueryOp;
- [WalletApiOperation.ListCurrencies]: ListCurrenciesOp;
[WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp;
[WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp;
[WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp;
@@ -1149,6 +1292,7 @@ export type WalletOperations = {
[WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp;
[WalletApiOperation.ForgetKnownBankAccounts]: ForgetKnownBankAccountsOp;
[WalletApiOperation.SetExchangeTosAccepted]: SetExchangeTosAcceptedOp;
+ [WalletApiOperation.SetExchangeTosForgotten]: SetExchangeTosForgottenOp;
[WalletApiOperation.GetExchangeTos]: GetExchangeTosOp;
[WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp;
[WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp;
@@ -1184,9 +1328,9 @@ export type WalletOperations = {
[WalletApiOperation.ValidateIban]: ValidateIbanOp;
[WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinalOp;
[WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
- [WalletApiOperation.TestingWaitTasksProcessed]: TestingWaitTasksProcessedOp;
[WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
[WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
+ [WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp;
[WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp;
[WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
[WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp;
@@ -1194,6 +1338,25 @@ export type WalletOperations = {
[WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp;
[WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp;
[WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp;
+ [WalletApiOperation.TestingInfiniteTransactionLoop]: any;
+ [WalletApiOperation.DeleteExchange]: DeleteExchangeOp;
+ [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp;
+ [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp;
+ [WalletApiOperation.ListGlobalCurrencyExchanges]: ListGlobalCurrencyExchangesOp;
+ [WalletApiOperation.AddGlobalCurrencyAuditor]: AddGlobalCurrencyAuditorOp;
+ [WalletApiOperation.RemoveGlobalCurrencyAuditor]: RemoveGlobalCurrencyAuditorOp;
+ [WalletApiOperation.AddGlobalCurrencyExchange]: AddGlobalCurrencyExchangeOp;
+ [WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp;
+ [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp;
+ [WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp;
+ [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp;
+ [WalletApiOperation.TestingPing]: TestingPingOp;
+ [WalletApiOperation.Shutdown]: ShutdownOp;
+ [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp;
+ [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp;
+ [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp;
+ [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp;
+ [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp;
};
export type WalletCoreRequestType<
@@ -1212,15 +1375,3 @@ export interface WalletCoreApiClient {
payload: WalletCoreRequestType<Op>,
): Promise<WalletCoreResponseType<Op>>;
}
-
-type Primitives = string | number | boolean;
-
-type RecursivePartial<T extends object> = {
- [P in keyof T]?: T[P] extends Array<infer U extends object>
- ? Array<RecursivePartial<U>>
- : T[P] extends Array<infer J extends Primitives>
- ? Array<J>
- : T[P] extends object
- ? RecursivePartial<T[P]>
- : T[P];
-} & object;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index 2d422e59c..68da15410 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -22,13 +22,16 @@
/**
* Imports.
*/
-import { IDBFactory } from "@gnu-taler/idb-bridge";
+import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
+ ActiveTask,
+ AmountJson,
AmountString,
Amounts,
+ AsyncCondition,
+ CancellationToken,
CoinDumpJson,
- CoinRefreshRequest,
CoinStatus,
CoreApiResponse,
CreateStoredBackupResponse,
@@ -40,43 +43,56 @@ import {
InitResponse,
KnownBankAccounts,
KnownBankAccountsInfo,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
Logger,
- WithdrawalDetailsForAmount,
- MerchantUsingTemplateDetails,
NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ ObservableHttpClientLibrary,
+ OpenedPromise,
+ PartialWalletRunConfig,
PrepareWithdrawExchangeRequest,
PrepareWithdrawExchangeResponse,
RecoverStoredBackupRequest,
- RefreshReason,
- ScopeType,
StoredBackupList,
TalerError,
TalerErrorCode,
+ TalerProtocolTimestamp,
TalerUriAction,
- TaskThrottler,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionsResponse,
TestingWaitTransactionRequest,
- TransactionState,
+ TimerAPI,
+ TimerGroup,
TransactionType,
- URL,
ValidateIbanResponse,
WalletCoreVersion,
WalletNotification,
+ WalletRunConfig,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
codecForAbortTransaction,
codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest,
- codecForAcceptManualWithdrawalRequet,
+ codecForAcceptManualWithdrawalRequest,
codecForAcceptPeerPullPaymentRequest,
- codecForAcceptTipRequest,
codecForAddExchangeRequest,
+ codecForAddGlobalCurrencyAuditorRequest,
+ codecForAddGlobalCurrencyExchangeRequest,
codecForAddKnownBankAccounts,
codecForAny,
codecForApplyDevExperiment,
+ codecForCanonicalizeBaseUrlRequest,
+ codecForCheckPayTemplateRequest,
codecForCheckPeerPullPaymentRequest,
codecForCheckPeerPushDebitRequest,
codecForConfirmPayRequest,
codecForConfirmPeerPushPaymentRequest,
+ codecForConfirmWithdrawalRequestRequest,
codecForConvertAmountRequest,
codecForCreateDepositGroupRequest,
+ codecForDeleteExchangeRequest,
codecForDeleteStoredBackupRequest,
codecForDeleteTransactionRequest,
codecForFailTransactionRequest,
@@ -87,26 +103,29 @@ import {
codecForGetContractTermsDetails,
codecForGetCurrencyInfoRequest,
codecForGetExchangeEntryByUrlRequest,
+ codecForGetExchangeResourcesRequest,
codecForGetExchangeTosRequest,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest,
+ codecForInitRequest,
codecForInitiatePeerPullPaymentRequest,
codecForInitiatePeerPushDebitRequest,
codecForIntegrationTestArgs,
codecForIntegrationTestV2Args,
codecForListExchangesForScopedCurrencyRequest,
codecForListKnownBankAccounts,
- codecForMerchantPostOrderResponse,
+ codecForPrepareBankIntegratedWithdrawalRequest,
codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePayTemplateRequest,
codecForPreparePeerPullPaymentRequest,
codecForPreparePeerPushCreditRequest,
codecForPrepareRefundRequest,
- codecForPrepareRewardRequest,
codecForPrepareWithdrawExchangeRequest,
codecForRecoverStoredBackupRequest,
+ codecForRemoveGlobalCurrencyAuditorRequest,
+ codecForRemoveGlobalCurrencyExchangeRequest,
codecForResumeTransaction,
codecForRetryTransactionRequest,
codecForSetCoinSuspendedRequest,
@@ -115,6 +134,9 @@ import {
codecForStartRefundQueryRequest,
codecForSuspendTransaction,
codecForTestPayArgs,
+ codecForTestingGetDenomStatsRequest,
+ codecForTestingGetReserveHistoryRequest,
+ codecForTestingListTasksForTransactionRequest,
codecForTestingSetTimetravelRequest,
codecForTransactionByIdRequest,
codecForTransactionsRequest,
@@ -123,53 +145,26 @@ import {
codecForUserAttentionsRequest,
codecForValidateIbanRequest,
codecForWithdrawTestBalance,
- constructPayUri,
- durationFromSpec,
- durationMin,
getErrorDetailFromException,
j2s,
- parsePayTemplateUri,
+ openPromise,
parsePaytoUri,
parseTalerUri,
+ performanceNow,
+ safeStringifyException,
sampleWalletCoreTransactions,
setDangerousTimetravel,
validateIban,
} from "@gnu-taler/taler-util";
-import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
-import {
- CryptoDispatcher,
- CryptoWorkerFactory,
-} from "./crypto/workers/crypto-dispatcher.js";
-import {
- CoinSourceType,
- ConfigRecordKey,
- DenominationRecord,
- WalletStoresV1,
- clearDatabase,
- exportDb,
- importDb,
- openStoredBackupsDatabase,
- openTalerDatabase,
-} from "./db.js";
-import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
- ActiveLongpollInfo,
- CancelFn,
- ExchangeOperations,
- InternalWalletState,
- MerchantInfo,
- MerchantOperations,
- NotificationListener,
- RecoupOperations,
- RefreshOperations,
-} from "./internal-wallet-state.js";
+ readSuccessResponseJsonOrThrow,
+ type HttpRequestLibrary,
+} from "@gnu-taler/taler-util/http";
import {
getUserAttentions,
getUserAttentionsUnreadCount,
markAttentionRequestAsRead,
-} from "./operations/attention.js";
+} from "./attention.js";
import {
addBackupProvider,
codecForAddBackupProviderRequest,
@@ -178,382 +173,187 @@ import {
getBackupInfo,
getBackupRecovery,
loadBackupRecovery,
- processBackupForProvider,
removeBackupProvider,
runBackupCycle,
setWalletDeviceId,
-} from "./operations/backup/index.js";
-import { getBalanceDetail, getBalances } from "./operations/balance.js";
+} from "./backup/index.js";
+import { getBalanceDetail, getBalances } from "./balance.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- makeExchangeListItem,
- runTaskWithErrorReporting,
-} from "./operations/common.js";
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
import {
- computeDepositTransactionStatus,
+ CoinSourceType,
+ ConfigRecordKey,
+ DenominationRecord,
+ WalletDbReadOnlyTransaction,
+ WalletStoresV1,
+ clearDatabase,
+ exportDb,
+ importDb,
+ openStoredBackupsDatabase,
+ openTalerDatabase,
+ timestampAbsoluteFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ checkDepositGroup,
createDepositGroup,
generateDepositGroupTxId,
- prepareDepositGroup,
- processDepositGroup,
-} from "./operations/deposits.js";
+} from "./deposits.js";
+import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
+ ReadyExchangeSummary,
acceptExchangeTermsOfService,
addPresetExchangeEntry,
+ deleteExchange,
fetchFreshExchange,
+ forgetExchangeTermsOfService,
getExchangeDetailedInfo,
- getExchangeDetails,
+ getExchangeResources,
getExchangeTos,
- getExchanges,
- updateExchangeFromUrlHandler,
-} from "./operations/exchanges.js";
-import { getMerchantInfo } from "./operations/merchants.js";
+ listExchanges,
+ lookupExchangeByUri,
+} from "./exchanges.js";
+import {
+ convertDepositAmount,
+ convertPeerPushAmount,
+ convertWithdrawalAmount,
+ getMaxDepositAmount,
+ getMaxPeerPushAmount,
+} from "./instructedAmountConversion.js";
import {
- computePayMerchantTransactionState,
- computeRefundTransactionState,
+ ObservableDbAccess,
+ ObservableTaskScheduler,
+ observeTalerCrypto,
+} from "./observable-wrappers.js";
+import {
+ checkPayForTemplate,
confirmPay,
getContractTermsDetails,
+ preparePayForTemplate,
preparePayForUri,
- processPurchase,
sharePayment,
startQueryRefund,
startRefundQueryForUri,
-} from "./operations/pay-merchant.js";
+} from "./pay-merchant.js";
import {
checkPeerPullPaymentInitiation,
- computePeerPullCreditTransactionState,
initiatePeerPullPayment,
- processPeerPullCredit,
-} from "./operations/pay-peer-pull-credit.js";
+} from "./pay-peer-pull-credit.js";
import {
- computePeerPullDebitTransactionState,
confirmPeerPullDebit,
preparePeerPullDebit,
- processPeerPullDebit,
-} from "./operations/pay-peer-pull-debit.js";
+} from "./pay-peer-pull-debit.js";
import {
- computePeerPushCreditTransactionState,
confirmPeerPushCredit,
preparePeerPushCredit,
- processPeerPushCredit,
-} from "./operations/pay-peer-push-credit.js";
+} from "./pay-peer-push-credit.js";
import {
checkPeerPushDebit,
- computePeerPushDebitTransactionState,
initiatePeerPushDebit,
- processPeerPushDebit,
-} from "./operations/pay-peer-push-debit.js";
-import { getPendingOperations } from "./operations/pending.js";
-import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
+} from "./pay-peer-push-debit.js";
import {
- autoRefresh,
- computeRefreshTransactionState,
- createRefreshGroup,
- processRefreshGroup,
-} from "./operations/refresh.js";
+ AfterCommitInfo,
+ DbAccess,
+ DbAccessImpl,
+ TriggerSpec,
+} from "./query.js";
+import { forceRefresh } from "./refresh.js";
import {
- acceptTip,
- computeRewardTransactionStatus,
- prepareTip,
- processTip,
-} from "./operations/reward.js";
+ TaskScheduler,
+ TaskSchedulerImpl,
+ convertTaskToTransactionId,
+ listTaskForTransactionId,
+} from "./shepherd.js";
import {
runIntegrationTest,
runIntegrationTest2,
testPay,
+ waitTasksDone,
waitTransactionState,
+ waitUntilAllTransactionsFinal,
waitUntilRefreshesDone,
- waitUntilTasksProcessed,
- waitUntilTransactionsFinal,
withdrawTestBalance,
-} from "./operations/testing.js";
+} from "./testing.js";
import {
abortTransaction,
+ constructTransactionIdentifier,
deleteTransaction,
failTransaction,
getTransactionById,
getTransactions,
+ getWithdrawalTransactionByUri,
parseTransactionIdentifier,
resumeTransaction,
retryTransaction,
suspendTransaction,
-} from "./operations/transactions.js";
-import {
- acceptWithdrawalFromUri,
- computeWithdrawalTransactionStatus,
- createManualWithdrawal,
- getExchangeWithdrawalInfo,
- getWithdrawalDetailsForUri,
- processWithdrawalGroup,
-} from "./operations/withdraw.js";
-import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
-import { assertUnreachable } from "./util/assertUnreachable.js";
-import {
- convertDepositAmount,
- convertPeerPushAmount,
- convertWithdrawalAmount,
- getMaxDepositAmount,
- getMaxPeerPushAmount,
-} from "./util/instructedAmountConversion.js";
-import { checkDbInvariant } from "./util/invariants.js";
-import {
- AsyncCondition,
- OpenedPromise,
- openPromise,
-} from "./util/promiseUtils.js";
-import {
- DbAccess,
- GetReadOnlyAccess,
- GetReadWriteAccess,
-} from "./util/query.js";
-import { TimerAPI, TimerGroup } from "./util/timer.js";
+} from "./transactions.js";
import {
WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_COREBANK_API_PROTOCOL_VERSION,
- WALLET_CORE_API_IMPLEMENTATION_VERSION,
+ WALLET_CORE_API_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_MERCHANT_PROTOCOL_VERSION,
} from "./versions.js";
import {
WalletApiOperation,
- WalletConfig,
- WalletConfigParameter,
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
+import {
+ acceptWithdrawalFromUri,
+ confirmWithdrawal,
+ createManualWithdrawal,
+ getWithdrawalDetailsForAmount,
+ getWithdrawalDetailsForUri,
+ prepareBankIntegratedWithdrawal,
+} from "./withdraw.js";
const logger = new Logger("wallet.ts");
/**
- * Call the right handler for a pending operation without doing
- * any special error handling.
- */
-async function callOperationHandler(
- ws: InternalWalletState,
- pending: PendingTaskInfo,
-): Promise<TaskRunResult> {
- switch (pending.type) {
- case PendingTaskType.ExchangeUpdate:
- return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl);
- case PendingTaskType.Refresh:
- return await processRefreshGroup(ws, pending.refreshGroupId);
- case PendingTaskType.Withdraw:
- return await processWithdrawalGroup(ws, pending.withdrawalGroupId);
- case PendingTaskType.RewardPickup:
- return await processTip(ws, pending.tipId);
- case PendingTaskType.Purchase:
- return await processPurchase(ws, pending.proposalId);
- case PendingTaskType.Recoup:
- return await processRecoupGroup(ws, pending.recoupGroupId);
- case PendingTaskType.ExchangeCheckRefresh:
- return await autoRefresh(ws, pending.exchangeBaseUrl);
- case PendingTaskType.Deposit:
- return await processDepositGroup(ws, pending.depositGroupId);
- case PendingTaskType.Backup:
- return await processBackupForProvider(ws, pending.backupProviderBaseUrl);
- case PendingTaskType.PeerPushDebit:
- return await processPeerPushDebit(ws, pending.pursePub);
- case PendingTaskType.PeerPullCredit:
- return await processPeerPullCredit(ws, pending.pursePub);
- case PendingTaskType.PeerPullDebit:
- return await processPeerPullDebit(ws, pending.peerPullDebitId);
- case PendingTaskType.PeerPushCredit:
- return await processPeerPushCredit(ws, pending.peerPushCreditId);
- default:
- return assertUnreachable(pending);
- }
- throw Error(`not reached ${pending.type}`);
-}
-
-/**
- * Process pending operations.
- */
-export async function runPending(ws: InternalWalletState): Promise<void> {
- const pendingOpsResponse = await getPendingOperations(ws);
- for (const p of pendingOpsResponse.pendingOperations) {
- if (!AbsoluteTime.isExpired(p.timestampDue)) {
- continue;
- }
- await runTaskWithErrorReporting(ws, p.id, async () => {
- logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
- return await callOperationHandler(ws, p);
- });
- }
-}
-
-export interface RetryLoopOpts {
- /**
- * Stop when the number of retries is exceeded for any pending
- * operation.
- */
- maxRetries?: number;
-
- /**
- * Stop the retry loop when all lifeness-giving pending operations
- * are done.
- *
- * Defaults to false.
- */
- stopWhenDone?: boolean;
-}
-
-export interface TaskLoopResult {
- /**
- * Was the maximum number of retries exceeded in a task?
- */
- retriesExceeded: boolean;
-}
-
-/**
- * Main retry loop of the wallet.
+ * Execution context for code that is run in the wallet.
*
- * Looks up pending operations from the wallet, runs them, repeat.
+ * Typically the execution context is either for a wallet-core
+ * request handler or for a shepherded task.
*/
-async function runTaskLoop(
- ws: InternalWalletState,
- opts: RetryLoopOpts = {},
-): Promise<TaskLoopResult> {
- logger.trace(`running task loop opts=${j2s(opts)}`);
- if (ws.isTaskLoopRunning) {
- logger.warn(
- "task loop already running, nesting the wallet-core task loop is deprecated and should be avoided",
- );
- }
- const throttler = new TaskThrottler();
- ws.isTaskLoopRunning = true;
- let retriesExceeded = false;
- for (let iteration = 0; !ws.stopped; iteration++) {
- const pending = await getPendingOperations(ws);
- logger.trace(`pending operations: ${j2s(pending)}`);
- let numGivingLiveness = 0;
- let numDue = 0;
- let numThrottled = 0;
- let minDue: AbsoluteTime = AbsoluteTime.never();
-
- for (const p of pending.pendingOperations) {
- const maxRetries = opts.maxRetries;
-
- if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
- retriesExceeded = true;
- logger.warn(
- `skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
- );
- continue;
- }
- if (p.givesLifeness) {
- numGivingLiveness++;
- }
- if (!p.isDue) {
- continue;
- }
- numDue++;
-
- const isThrottled = throttler.applyThrottle(p.id);
-
- if (isThrottled) {
- logger.warn(
- `task ${p.id} throttled, this is very likely a bug in wallet-core, please report`,
- );
- numDue--;
- numThrottled++;
- } else {
- minDue = AbsoluteTime.min(minDue, p.timestampDue);
- }
- }
-
- logger.trace(
- `running task loop, iter=${iteration}, #tasks=${pending.pendingOperations.length} #lifeness=${numGivingLiveness}, #due=${numDue} #trottled=${numThrottled}`,
- );
+export interface WalletExecutionContext {
+ readonly ws: InternalWalletState;
+ readonly cryptoApi: TalerCryptoInterface;
+ readonly cancellationToken: CancellationToken;
+ readonly http: HttpRequestLibrary;
+ readonly db: DbAccess<typeof WalletStoresV1>;
+ readonly oc: ObservabilityContext;
+ readonly taskScheduler: TaskScheduler;
+}
- if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
- logger.warn(`stopping, as no pending operations have lifeness`);
- ws.isTaskLoopRunning = false;
- return {
- retriesExceeded,
- };
- }
+export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
+export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
- if (ws.stopped) {
- ws.isTaskLoopRunning = false;
- return {
- retriesExceeded,
- };
- }
+export type NotificationListener = (n: WalletNotification) => void;
- // Make sure that we run tasks that don't give lifeness at least
- // one time.
- if (iteration !== 0 && numDue === 0) {
- // We've executed pending, due operations at least one.
- // Now we don't have any more operations available,
- // and need to wait.
-
- // Wait for at most 5 seconds to the next check.
- const dt = durationMin(
- durationFromSpec({
- seconds: 5,
- }),
- Duration.getRemaining(minDue),
- );
- logger.trace(`waiting for at most ${dt.d_ms} ms`);
- const timeout = ws.timerGroup.resolveAfter(dt);
- // Wait until either the timeout, or we are notified (via the latch)
- // that more work might be available.
- await Promise.race([timeout, ws.workAvailable.wait()]);
- logger.trace(`done waiting for available work`);
- } else {
- logger.trace(
- `running ${pending.pendingOperations.length} pending operations`,
- );
- for (const p of pending.pendingOperations) {
- if (!AbsoluteTime.isExpired(p.timestampDue)) {
- continue;
- }
- logger.trace(`running task ${p.id}`);
- const res = await runTaskWithErrorReporting(ws, p.id, async () => {
- return await callOperationHandler(ws, p);
- });
- if (!(ws.stopped && res.type === TaskRunResultType.Error)) {
- ws.notify({
- type: NotificationType.PendingOperationProcessed,
- id: p.id,
- taskResultType: res.type,
- });
- }
- if (ws.stopped) {
- ws.isTaskLoopRunning = false;
- return {
- retriesExceeded,
- };
- }
- }
- }
- }
- logger.trace("exiting wallet task loop");
- ws.isTaskLoopRunning = false;
- return {
- retriesExceeded,
- };
-}
+type CancelFn = () => void;
/**
* Insert the hard-coded defaults for exchanges, coins and
* auditors into the database, unless these defaults have
* already been applied.
*/
-async function fillDefaults(ws: InternalWalletState): Promise<void> {
+async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
const notifications: WalletNotification[] = [];
- await ws.db
- .mktx((x) => [x.config, x.exchanges, x.exchangeDetails])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["config", "exchanges"] },
+ async (tx) => {
const appliedRec = await tx.config.get("currencyDefaultsApplied");
let alreadyApplied = appliedRec ? !!appliedRec.value : false;
if (alreadyApplied) {
logger.trace("defaults already applied");
return;
}
- for (const exch of ws.config.builtin.exchanges) {
+ for (const exch of wex.ws.config.builtin.exchanges) {
const resp = await addPresetExchangeEntry(
tx,
exch.exchangeBaseUrl,
@@ -567,10 +367,31 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> {
key: ConfigRecordKey.CurrencyDefaultsApplied,
value: true,
});
- });
+ },
+ );
for (const notif of notifications) {
- ws.notify(notif);
+ wex.ws.notify(notif);
+ }
+}
+
+export async function getDenomInfo(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+): Promise<DenominationInfo | undefined> {
+ const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`;
+ const cached = wex.ws.denomInfoCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+ const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
+ if (d) {
+ const denomInfo = DenominationRecord.toDenomInfo(d);
+ wex.ws.denomInfoCache.put(cacheKey, denomInfo);
+ return denomInfo;
}
+ return undefined;
}
/**
@@ -578,79 +399,73 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> {
* previous withdrawals.
*/
async function listKnownBankAccounts(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
currency?: string,
): Promise<KnownBankAccounts> {
const accounts: KnownBankAccountsInfo[] = [];
- await ws.db
- .mktx((x) => [x.bankAccounts])
- .runReadOnly(async (tx) => {
- const knownAccounts = await tx.bankAccounts.iter().toArray();
- for (const r of knownAccounts) {
- if (currency && currency !== r.currency) {
- continue;
- }
- const payto = parsePaytoUri(r.uri);
- if (payto) {
- accounts.push({
- uri: payto,
- alias: r.alias,
- kyc_completed: r.kycCompleted,
- currency: r.currency,
- });
- }
+ await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const knownAccounts = await tx.bankAccounts.iter().toArray();
+ for (const r of knownAccounts) {
+ if (currency && currency !== r.currency) {
+ continue;
}
- });
+ const payto = parsePaytoUri(r.uri);
+ if (payto) {
+ accounts.push({
+ uri: payto,
+ alias: r.alias,
+ kyc_completed: r.kycCompleted,
+ currency: r.currency,
+ });
+ }
+ }
+ });
return { accounts };
}
/**
*/
async function addKnownBankAccounts(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
payto: string,
alias: string,
currency: string,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.bankAccounts])
- .runReadWrite(async (tx) => {
- tx.bankAccounts.put({
- uri: payto,
- alias: alias,
- currency: currency,
- kycCompleted: false,
- });
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ tx.bankAccounts.put({
+ uri: payto,
+ alias: alias,
+ currency: currency,
+ kycCompleted: false,
});
+ });
return;
}
/**
*/
async function forgetKnownBankAccounts(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
payto: string,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.bankAccounts])
- .runReadWrite(async (tx) => {
- const account = await tx.bankAccounts.get(payto);
- if (!account) {
- throw Error(`account not found: ${payto}`);
- }
- tx.bankAccounts.delete(account.uri);
- });
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const account = await tx.bankAccounts.get(payto);
+ if (!account) {
+ throw Error(`account not found: ${payto}`);
+ }
+ tx.bankAccounts.delete(account.uri);
+ });
return;
}
async function setCoinSuspended(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
coinPub: string,
suspended: boolean,
): Promise<void> {
- await ws.db
- .mktx((x) => [x.coins, x.coinAvailability])
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability"] },
+ async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
@@ -682,18 +497,19 @@ async function setCoinSuspended(
}
await tx.coins.put(c);
await tx.coinAvailability.put(coinAvailability);
- });
+ },
+ );
}
/**
* Dump the public information of coins we have in an easy-to-process format.
*/
-async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
+async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
const coinsJson: CoinDumpJson = { coins: [] };
logger.info("dumping coins");
- await ws.db
- .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups])
- .runReadOnly(async (tx) => {
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
const coins = await tx.coins.iter().toArray();
for (const c of coins) {
const denom = await tx.denominations.get([
@@ -713,8 +529,8 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
if (cs.type == CoinSourceType.Withdraw) {
withdrawalReservePub = cs.reservePub;
}
- const denomInfo = await ws.getDenomInfo(
- ws,
+ const denomInfo = await getDenomInfo(
+ wex,
tx,
c.exchangeBaseUrl,
c.denomPubHash,
@@ -741,20 +557,22 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
: undefined,
});
}
- });
+ },
+ );
return coinsJson;
}
/**
* Get an API client from an internal wallet state object.
*/
-export async function getClientFromWalletState(
+let id = 0;
+async function getClientFromWalletState(
ws: InternalWalletState,
): Promise<WalletCoreApiClient> {
- let id = 0;
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
- const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
+ id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100);
+ const res = await handleCoreApiRequest(ws, op, String(id), payload);
switch (res.type) {
case "error":
throw TalerError.fromUncheckedDetail(res.error);
@@ -767,12 +585,12 @@ export async function getClientFromWalletState(
}
async function createStoredBackup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<CreateStoredBackupResponse> {
- const backup = await exportDb(ws.idb);
- const backupsDb = await openStoredBackupsDatabase(ws.idb);
+ const backup = await exportDb(wex.ws.idb);
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
const name = `backup-${new Date().getTime()}`;
- await backupsDb.mktxAll().runReadWrite(async (tx) => {
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
await tx.backupMeta.add({
name,
});
@@ -784,13 +602,13 @@ async function createStoredBackup(
}
async function listStoredBackups(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
): Promise<StoredBackupList> {
const storedBackups: StoredBackupList = {
storedBackups: [],
};
- const backupsDb = await openStoredBackupsDatabase(ws.idb);
- await backupsDb.mktxAll().runReadWrite(async (tx) => {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
await tx.backupMeta.iter().forEach((x) => {
storedBackups.storedBackups.push({
name: x.name,
@@ -801,24 +619,24 @@ async function listStoredBackups(
}
async function deleteStoredBackup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: DeleteStoredBackupRequest,
): Promise<void> {
- const backupsDb = await openStoredBackupsDatabase(ws.idb);
- await backupsDb.mktxAll().runReadWrite(async (tx) => {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
await tx.backupData.delete(req.name);
await tx.backupMeta.delete(req.name);
});
}
async function recoverStoredBackup(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: RecoverStoredBackupRequest,
): Promise<void> {
logger.info(`Recovering stored backup ${req.name}`);
const { name } = req;
- const backupsDb = await openStoredBackupsDatabase(ws.idb);
- const bd = await backupsDb.mktxAll().runReadWrite(async (tx) => {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
const backupMeta = tx.backupMeta.get(name);
if (!backupMeta) {
throw Error("backup not found");
@@ -830,12 +648,12 @@ async function recoverStoredBackup(
return backupData;
});
logger.info(`backup found, now importing`);
- await importDb(ws.db.idbHandle(), bd);
+ await importDb(wex.db.idbHandle(), bd);
logger.info(`import done`);
}
async function handlePrepareWithdrawExchange(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
req: PrepareWithdrawExchangeRequest,
): Promise<PrepareWithdrawExchangeResponse> {
const parsedUri = parseTalerUri(req.talerUri);
@@ -843,10 +661,7 @@ async function handlePrepareWithdrawExchange(
throw Error("expected a taler://withdraw-exchange URI");
}
const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
- if (exchange.masterPub != parsedUri.exchangePub) {
- throw Error("mismatch of exchange master public key (URI vs actual)");
- }
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
if (parsedUri.amount) {
const amt = Amounts.parseOrThrow(parsedUri.amount);
if (amt.currency !== exchange.currency) {
@@ -860,14 +675,27 @@ async function handlePrepareWithdrawExchange(
}
/**
+ * Response returned from the pending operations API.
+ *
+ * @deprecated this is a placeholder for the response type of a deprecated wallet-core request.
+ */
+export interface PendingOperationsResponse {
+ /**
+ * List of pending operations.
+ */
+ pendingOperations: any[];
+}
+
+/**
* Implementation of the "wallet-core" API.
*/
-async function dispatchRequestInternal<Op extends WalletApiOperation>(
- ws: InternalWalletState,
+async function dispatchRequestInternal(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
operation: WalletApiOperation,
payload: unknown,
): Promise<WalletCoreResponseType<typeof operation>> {
- if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) {
+ if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) {
throw Error(
`wallet must be initialized before running operation ${operation}`,
);
@@ -876,56 +704,95 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
// definitions we already have?
switch (operation) {
case WalletApiOperation.CreateStoredBackup:
- return createStoredBackup(ws);
+ return createStoredBackup(wex);
case WalletApiOperation.DeleteStoredBackup: {
const req = codecForDeleteStoredBackupRequest().decode(payload);
- await deleteStoredBackup(ws, req);
+ await deleteStoredBackup(wex, req);
return {};
}
case WalletApiOperation.ListStoredBackups:
- return listStoredBackups(ws);
+ return listStoredBackups(wex);
case WalletApiOperation.RecoverStoredBackup: {
const req = codecForRecoverStoredBackupRequest().decode(payload);
- await recoverStoredBackup(ws, req);
+ await recoverStoredBackup(wex, req);
return {};
}
+ case WalletApiOperation.SetWalletRunConfig:
case WalletApiOperation.InitWallet: {
- logger.trace("initializing wallet");
- ws.initCalled = true;
- if (ws.config.testing.skipDefaults) {
+ const req = codecForInitRequest().decode(payload);
+
+ logger.info(`init request: ${j2s(req)}`);
+
+ if (wex.ws.initCalled) {
+ logger.info("initializing wallet (repeat initialization)");
+ } else {
+ logger.info("initializing wallet (first initialization)");
+ }
+
+ // Write to the DB to make sure that we're failing early in
+ // case the DB is not writeable.
+ try {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ tx.config.put({
+ key: ConfigRecordKey.LastInitInfo,
+ value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
+ });
+ });
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
+
+ wex.ws.initWithConfig(applyRunConfigDefaults(req.config));
+
+ if (wex.ws.config.testing.skipDefaults) {
logger.trace("skipping defaults");
} else {
logger.trace("filling defaults");
- await fillDefaults(ws);
+ await fillDefaults(wex);
}
const resp: InitResponse = {
- versionInfo: getVersion(ws),
+ versionInfo: getVersion(wex),
};
+
+ // After initialization, task loop should run.
+ await wex.taskScheduler.ensureRunning();
+
+ wex.ws.initCalled = true;
return resp;
}
case WalletApiOperation.WithdrawTestkudos: {
- await withdrawTestBalance(ws, {
+ await withdrawTestBalance(wex, {
amount: "TESTKUDOS:10" as AmountString,
corebankApiBaseUrl: "https://bank.test.taler.net/",
exchangeBaseUrl: "https://exchange.test.taler.net/",
});
return {
- versionInfo: getVersion(ws),
+ versionInfo: getVersion(wex),
};
}
case WalletApiOperation.WithdrawTestBalance: {
const req = codecForWithdrawTestBalance().decode(payload);
- await withdrawTestBalance(ws, req);
+ await withdrawTestBalance(wex, req);
return {};
}
+ case WalletApiOperation.TestingListTaskForTransaction: {
+ const req =
+ codecForTestingListTasksForTransactionRequest().decode(payload);
+ return {
+ taskIdList: listTaskForTransactionId(req.transactionId),
+ } satisfies TestingListTasksForTransactionsResponse;
+ }
case WalletApiOperation.RunIntegrationTest: {
const req = codecForIntegrationTestArgs().decode(payload);
- await runIntegrationTest(ws, req);
+ await runIntegrationTest(wex, req);
return {};
}
case WalletApiOperation.RunIntegrationTestV2: {
const req = codecForIntegrationTestV2Args().decode(payload);
- await runIntegrationTest2(ws, req);
+ await runIntegrationTest2(wex, req);
return {};
}
case WalletApiOperation.ValidateIban: {
@@ -938,66 +805,73 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.TestPay: {
const req = codecForTestPayArgs().decode(payload);
- return await testPay(ws, req);
+ return await testPay(wex, req);
}
case WalletApiOperation.GetTransactions: {
const req = codecForTransactionsRequest().decode(payload);
- return await getTransactions(ws, req);
+ return await getTransactions(wex, req);
}
case WalletApiOperation.GetTransactionById: {
const req = codecForTransactionByIdRequest().decode(payload);
- return await getTransactionById(ws, req);
+ return await getTransactionById(wex, req);
+ }
+ case WalletApiOperation.GetWithdrawalTransactionByUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalTransactionByUri(wex, req);
}
case WalletApiOperation.AddExchange: {
const req = codecForAddExchangeRequest().decode(payload);
- await fetchFreshExchange(ws, req.exchangeBaseUrl, {
- expectedMasterPub: req.masterPub,
- });
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {});
+ return {};
+ }
+ case WalletApiOperation.TestingPing: {
return {};
}
case WalletApiOperation.UpdateExchangeEntry: {
const req = codecForUpdateExchangeEntryRequest().decode(payload);
- await fetchFreshExchange(ws, req.exchangeBaseUrl, {
- forceUpdate: true,
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ forceUpdate: !!req.force,
});
return {};
}
+ case WalletApiOperation.TestingGetDenomStats: {
+ const req = codecForTestingGetDenomStatsRequest().decode(payload);
+ const denomStats: TestingGetDenomStatsResponse = {
+ numKnown: 0,
+ numLost: 0,
+ numOffered: 0,
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ req.exchangeBaseUrl,
+ );
+ for (const d of denoms) {
+ denomStats.numKnown++;
+ if (d.isOffered) {
+ denomStats.numOffered++;
+ }
+ if (d.isLost) {
+ denomStats.numLost++;
+ }
+ }
+ },
+ );
+ return denomStats;
+ }
case WalletApiOperation.ListExchanges: {
- return await getExchanges(ws);
+ return await listExchanges(wex);
}
case WalletApiOperation.GetExchangeEntryByUrl: {
const req = codecForGetExchangeEntryByUrlRequest().decode(payload);
- const exchangeEntry = await ws.db
- .mktx((x) => [
- x.exchanges,
- x.exchangeDetails,
- x.denominations,
- x.operationRetries,
- ])
- .runReadOnly(async (tx) => {
- const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
- if (!exchangeRec) {
- throw Error("exchange not found");
- }
- const exchangeDetails = await getExchangeDetails(
- tx,
- exchangeRec.baseUrl,
- );
- const opRetryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(exchangeRec),
- );
- return makeExchangeListItem(
- exchangeRec,
- exchangeDetails,
- opRetryRecord?.lastError,
- );
- });
- return exchangeEntry;
+ return lookupExchangeByUri(wex, req);
}
case WalletApiOperation.ListExchangesForScopedCurrency: {
const req =
codecForListExchangesForScopedCurrencyRequest().decode(payload);
- const exchangesResp = await getExchanges(ws);
+ const exchangesResp = await listExchanges(wex);
const result: ExchangesShortListResponse = {
exchanges: [],
};
@@ -1014,107 +888,132 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.GetExchangeDetailedInfo: {
const req = codecForAddExchangeRequest().decode(payload);
- return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl);
+ return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl);
}
case WalletApiOperation.ListKnownBankAccounts: {
const req = codecForListKnownBankAccounts().decode(payload);
- return await listKnownBankAccounts(ws, req.currency);
+ return await listKnownBankAccounts(wex, req.currency);
}
case WalletApiOperation.AddKnownBankAccounts: {
const req = codecForAddKnownBankAccounts().decode(payload);
- await addKnownBankAccounts(ws, req.payto, req.alias, req.currency);
+ await addKnownBankAccounts(wex, req.payto, req.alias, req.currency);
return {};
}
case WalletApiOperation.ForgetKnownBankAccounts: {
const req = codecForForgetKnownBankAccounts().decode(payload);
- await forgetKnownBankAccounts(ws, req.payto);
+ await forgetKnownBankAccounts(wex, req.payto);
return {};
}
case WalletApiOperation.GetWithdrawalDetailsForUri: {
const req = codecForGetWithdrawalDetailsForUri().decode(payload);
- return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
+ return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri);
+ }
+ case WalletApiOperation.TestingGetReserveHistory: {
+ const req = codecForTestingGetReserveHistoryRequest().decode(payload);
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return tx.reserves.indexes.byReservePub.get(req.reservePub);
+ },
+ );
+ if (!reserve) {
+ throw Error("no reserve pub found");
+ }
+ const sigResp = await wex.cryptoApi.signReserveHistoryReq({
+ reservePriv: reserve.reservePriv,
+ startOffset: 0,
+ });
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const url = new URL(
+ `reserves/${req.reservePub}/history`,
+ exchangeBaseUrl,
+ );
+ const resp = await wex.http.fetch(url.href, {
+ headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig },
+ });
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForAny(),
+ );
+ return historyJson;
}
case WalletApiOperation.AcceptManualWithdrawal: {
- const req = codecForAcceptManualWithdrawalRequet().decode(payload);
- const res = await createManualWithdrawal(ws, {
+ const req = codecForAcceptManualWithdrawalRequest().decode(payload);
+ const res = await createManualWithdrawal(wex, {
amount: Amounts.parseOrThrow(req.amount),
exchangeBaseUrl: req.exchangeBaseUrl,
restrictAge: req.restrictAge,
+ forceReservePriv: req.forceReservePriv,
});
return res;
}
case WalletApiOperation.GetWithdrawalDetailsForAmount: {
const req =
codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
- const wi = await getExchangeWithdrawalInfo(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- req.restrictAge,
- );
- let numCoins = 0;
- for (const x of wi.selectedDenoms.selectedDenoms) {
- numCoins += x.count;
- }
- const amt = Amounts.parseOrThrow(req.amount);
- const resp: WithdrawalDetailsForAmount = {
- amountRaw: req.amount,
- amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
- paytoUris: wi.exchangePaytoUris,
- tosAccepted: wi.termsOfServiceAccepted,
- ageRestrictionOptions: wi.ageRestrictionOptions,
- withdrawalAccountsList: wi.exchangeCreditAccountDetails,
- numCoins,
- // FIXME: Once we have proper scope info support, return correct info here.
- scopeInfo: {
- type: ScopeType.Exchange,
- currency: amt.currency,
- url: req.exchangeBaseUrl,
- },
- };
+ const resp = await getWithdrawalDetailsForAmount(wex, cts, req);
return resp;
}
case WalletApiOperation.GetBalances: {
- return await getBalances(ws);
+ return await getBalances(wex);
}
case WalletApiOperation.GetBalanceDetail: {
const req = codecForGetBalanceDetailRequest().decode(payload);
- return await getBalanceDetail(ws, req);
+ return await getBalanceDetail(wex, req);
}
case WalletApiOperation.GetUserAttentionRequests: {
const req = codecForUserAttentionsRequest().decode(payload);
- return await getUserAttentions(ws, req);
+ return await getUserAttentions(wex, req);
}
case WalletApiOperation.MarkAttentionRequestAsRead: {
const req = codecForUserAttentionByIdRequest().decode(payload);
- return await markAttentionRequestAsRead(ws, req);
+ return await markAttentionRequestAsRead(wex, req);
}
case WalletApiOperation.GetUserAttentionUnreadCount: {
const req = codecForUserAttentionsRequest().decode(payload);
- return await getUserAttentionsUnreadCount(ws, req);
+ return await getUserAttentionsUnreadCount(wex, req);
}
case WalletApiOperation.GetPendingOperations: {
- return await getPendingOperations(ws);
+ // FIXME: Eventually remove the handler after deprecation period.
+ return {
+ pendingOperations: [],
+ } satisfies PendingOperationsResponse;
}
case WalletApiOperation.SetExchangeTosAccepted: {
const req = codecForAcceptExchangeTosRequest().decode(payload);
- await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
+ await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl);
+ return {};
+ }
+ case WalletApiOperation.SetExchangeTosForgotten: {
+ const req = codecForAcceptExchangeTosRequest().decode(payload);
+ await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl);
return {};
}
case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
const req =
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
- return await acceptWithdrawalFromUri(ws, {
+ return await acceptWithdrawalFromUri(wex, {
selectedExchange: req.exchangeBaseUrl,
talerWithdrawUri: req.talerWithdrawUri,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
});
}
+ case WalletApiOperation.ConfirmWithdrawal: {
+ const req = codecForConfirmWithdrawalRequestRequest().decode(payload);
+ return confirmWithdrawal(wex, req);
+ }
+ case WalletApiOperation.PrepareBankIntegratedWithdrawal: {
+ const req =
+ codecForPrepareBankIntegratedWithdrawalRequest().decode(payload);
+ return prepareBankIntegratedWithdrawal(wex, {
+ talerWithdrawUri: req.talerWithdrawUri,
+ selectedExchange: req.selectedExchange,
+ });
+ }
case WalletApiOperation.GetExchangeTos: {
const req = codecForGetExchangeTosRequest().decode(payload);
return getExchangeTos(
- ws,
+ wex,
req.exchangeBaseUrl,
req.acceptedFormat,
req.acceptLanguage,
@@ -1122,168 +1021,134 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.GetContractTermsDetails: {
const req = codecForGetContractTermsDetails().decode(payload);
- return getContractTermsDetails(ws, req.proposalId);
+ if (req.proposalId) {
+ // FIXME: deprecated path
+ return getContractTermsDetails(wex, req.proposalId);
+ }
+ if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag === TransactionType.Payment) {
+ return getContractTermsDetails(wex, parsedTx.proposalId);
+ }
+ throw Error("transactionId is not a payment transaction");
+ }
+ throw Error("transactionId missing");
}
case WalletApiOperation.RetryPendingNow: {
- // FIXME: Should we reset all operation retries here?
- await runPending(ws);
+ logger.error("retryPendingNow currently not implemented");
return {};
}
case WalletApiOperation.SharePayment: {
const req = codecForSharePaymentRequest().decode(payload);
- return await sharePayment(ws, req.merchantBaseUrl, req.orderId);
+ return await sharePayment(wex, req.merchantBaseUrl, req.orderId);
}
case WalletApiOperation.PrepareWithdrawExchange: {
const req = codecForPrepareWithdrawExchangeRequest().decode(payload);
- return handlePrepareWithdrawExchange(ws, req);
+ return handlePrepareWithdrawExchange(wex, req);
}
case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
- return await preparePayForUri(ws, req.talerPayUri);
+ return await preparePayForUri(wex, req.talerPayUri);
}
case WalletApiOperation.PreparePayForTemplate: {
const req = codecForPreparePayTemplateRequest().decode(payload);
- const url = parsePayTemplateUri(req.talerPayTemplateUri);
- const templateDetails: MerchantUsingTemplateDetails = {};
- if (!url) {
- throw Error("invalid taler-template URI");
- }
- if (
- url.templateParams.amount !== undefined &&
- typeof url.templateParams.amount === "string"
- ) {
- templateDetails.amount = (req.templateParams.amount ??
- url.templateParams.amount) as AmountString | undefined;
- }
- if (
- url.templateParams.summary !== undefined &&
- typeof url.templateParams.summary === "string"
- ) {
- templateDetails.summary =
- req.templateParams.summary ?? url.templateParams.summary;
- }
- const reqUrl = new URL(
- `templates/${url.templateId}`,
- url.merchantBaseUrl,
- );
- const httpReq = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: templateDetails,
- });
- const resp = await readSuccessResponseJsonOrThrow(
- httpReq,
- codecForMerchantPostOrderResponse(),
- );
-
- const payUri = constructPayUri(
- url.merchantBaseUrl,
- resp.order_id,
- "",
- resp.token,
- );
-
- return await preparePayForUri(ws, payUri);
+ return preparePayForTemplate(wex, req);
+ }
+ case WalletApiOperation.CheckPayForTemplate: {
+ const req = codecForCheckPayTemplateRequest().decode(payload);
+ return checkPayForTemplate(wex, req);
}
case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
- let proposalId;
+ let transactionId;
if (req.proposalId) {
// legacy client support
- proposalId = req.proposalId;
+ transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: req.proposalId,
+ });
} else if (req.transactionId) {
- const txIdParsed = parseTransactionIdentifier(req.transactionId);
- if (txIdParsed?.tag != TransactionType.Payment) {
- throw Error("payment transaction ID required");
- }
- proposalId = txIdParsed.proposalId;
+ transactionId = req.transactionId;
} else {
throw Error("transactionId or (deprecated) proposalId required");
}
- return await confirmPay(ws, proposalId, req.sessionId);
+ return await confirmPay(wex, transactionId, req.sessionId);
}
case WalletApiOperation.AbortTransaction: {
const req = codecForAbortTransaction().decode(payload);
- await abortTransaction(ws, req.transactionId);
+ await abortTransaction(wex, req.transactionId);
return {};
}
case WalletApiOperation.SuspendTransaction: {
const req = codecForSuspendTransaction().decode(payload);
- await suspendTransaction(ws, req.transactionId);
+ await suspendTransaction(wex, req.transactionId);
return {};
}
+ case WalletApiOperation.GetActiveTasks: {
+ const allTasksId = wex.taskScheduler.getActiveTasks();
+
+ const tasksInfo = await Promise.all(
+ allTasksId.map(async (id) => {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ return tx.operationRetries.get(id);
+ },
+ );
+ }),
+ );
+
+ const tasks = allTasksId.map((taskId, i): ActiveTask => {
+ const transaction = convertTaskToTransactionId(taskId);
+ const d = tasksInfo[i];
+
+ const firstTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.firstTry);
+ const nextTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.nextRetry);
+ const counter = d?.retryInfo.retryCounter;
+ const lastError = d?.lastError;
+
+ return {
+ taskId: taskId,
+ retryCounter: counter,
+ firstTry,
+ nextTry,
+ lastError,
+ transaction,
+ };
+ });
+ return { tasks };
+ }
case WalletApiOperation.FailTransaction: {
const req = codecForFailTransactionRequest().decode(payload);
- await failTransaction(ws, req.transactionId);
+ await failTransaction(wex, req.transactionId);
return {};
}
case WalletApiOperation.ResumeTransaction: {
const req = codecForResumeTransaction().decode(payload);
- await resumeTransaction(ws, req.transactionId);
+ await resumeTransaction(wex, req.transactionId);
return {};
}
case WalletApiOperation.DumpCoins: {
- return await dumpCoins(ws);
+ return await dumpCoins(wex);
}
case WalletApiOperation.SetCoinSuspended: {
const req = codecForSetCoinSuspendedRequest().decode(payload);
- await setCoinSuspended(ws, req.coinPub, req.suspended);
+ await setCoinSuspended(wex, req.coinPub, req.suspended);
return {};
}
case WalletApiOperation.TestingGetSampleTransactions:
return { transactions: sampleWalletCoreTransactions };
case WalletApiOperation.ForceRefresh: {
const req = codecForForceRefreshRequest().decode(payload);
- if (req.coinPubList.length == 0) {
- throw Error("refusing to create empty refresh group");
- }
- const refreshGroupId = await ws.db
- .mktx((x) => [
- x.refreshGroups,
- x.coinAvailability,
- x.denominations,
- x.coins,
- ])
- .runReadWrite(async (tx) => {
- let coinPubs: CoinRefreshRequest[] = [];
- for (const c of req.coinPubList) {
- const coin = await tx.coins.get(c);
- if (!coin) {
- throw Error(`coin (pubkey ${c}) not found`);
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- coinPubs.push({
- coinPub: c,
- amount: denom?.value,
- });
- }
- return await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(coinPubs[0].amount),
- coinPubs,
- RefreshReason.Manual,
- );
- });
- processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((x) => {
- logger.error(x);
- });
- return {
- refreshGroupId,
- };
- }
- case WalletApiOperation.PrepareReward: {
- const req = codecForPrepareRewardRequest().decode(payload);
- return await prepareTip(ws, req.talerRewardUri);
+ return await forceRefresh(wex, req);
}
case WalletApiOperation.StartRefundQueryForUri: {
const req = codecForPrepareRefundRequest().decode(payload);
- return await startRefundQueryForUri(ws, req.talerRefundUri);
+ return await startRefundQueryForUri(wex, req.talerRefundUri);
}
case WalletApiOperation.StartRefundQuery: {
const req = codecForStartRefundQueryRequest().decode(payload);
@@ -1294,38 +1159,30 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
if (txIdParsed.tag !== TransactionType.Payment) {
throw Error("expected payment transaction ID");
}
- await startQueryRefund(ws, txIdParsed.proposalId);
+ await startQueryRefund(wex, txIdParsed.proposalId);
return {};
}
- case WalletApiOperation.AcceptReward: {
- const req = codecForAcceptTipRequest().decode(payload);
- return await acceptTip(ws, req.walletRewardId);
- }
case WalletApiOperation.AddBackupProvider: {
const req = codecForAddBackupProviderRequest().decode(payload);
- return await addBackupProvider(ws, req);
+ return await addBackupProvider(wex, req);
}
case WalletApiOperation.RunBackupCycle: {
const req = codecForRunBackupCycle().decode(payload);
- await runBackupCycle(ws, req);
+ await runBackupCycle(wex, req);
return {};
}
case WalletApiOperation.RemoveBackupProvider: {
const req = codecForRemoveBackupProvider().decode(payload);
- await removeBackupProvider(ws, req);
+ await removeBackupProvider(wex, req);
return {};
}
case WalletApiOperation.ExportBackupRecovery: {
- const resp = await getBackupRecovery(ws);
+ const resp = await getBackupRecovery(wex);
return resp;
}
case WalletApiOperation.TestingWaitTransactionState: {
const req = payload as TestingWaitTransactionRequest;
- await waitTransactionState(ws, req.transactionId, req.txState);
- return {};
- }
- case WalletApiOperation.TestingWaitTasksProcessed: {
- await waitUntilTasksProcessed(ws);
+ await waitTransactionState(wex, req.transactionId, req.txState);
return {};
}
case WalletApiOperation.GetCurrencySpecification: {
@@ -1361,12 +1218,12 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
const defaultResp: GetCurrencySpecificationResponse = {
currencySpecification: {
- name: "Unknown",
+ name: req.scope.currency,
num_fractional_input_digits: 2,
num_fractional_normal_digits: 2,
num_fractional_trailing_zero_digits: 2,
alt_unit_names: {
- "0": "??",
+ "0": req.scope.currency,
},
},
};
@@ -1374,7 +1231,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
- await loadBackupRecovery(ws, req);
+ await loadBackupRecovery(wex, req);
return {};
}
// case WalletApiOperation.GetPlanForOperation: {
@@ -1383,31 +1240,31 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
// }
case WalletApiOperation.ConvertDepositAmount: {
const req = codecForConvertAmountRequest.decode(payload);
- return await convertDepositAmount(ws, req);
+ return await convertDepositAmount(wex, req);
}
case WalletApiOperation.GetMaxDepositAmount: {
const req = codecForGetAmountRequest.decode(payload);
- return await getMaxDepositAmount(ws, req);
+ return await getMaxDepositAmount(wex, req);
}
case WalletApiOperation.ConvertPeerPushAmount: {
const req = codecForConvertAmountRequest.decode(payload);
- return await convertPeerPushAmount(ws, req);
+ return await convertPeerPushAmount(wex, req);
}
case WalletApiOperation.GetMaxPeerPushAmount: {
const req = codecForGetAmountRequest.decode(payload);
- return await getMaxPeerPushAmount(ws, req);
+ return await getMaxPeerPushAmount(wex, req);
}
case WalletApiOperation.ConvertWithdrawalAmount: {
const req = codecForConvertAmountRequest.decode(payload);
- return await convertWithdrawalAmount(ws, req);
+ return await convertWithdrawalAmount(wex, req);
}
case WalletApiOperation.GetBackupInfo: {
- const resp = await getBackupInfo(ws);
+ const resp = await getBackupInfo(wex);
return resp;
}
case WalletApiOperation.PrepareDeposit: {
const req = codecForPrepareDepositRequest().decode(payload);
- return await prepareDepositGroup(ws, req);
+ return await checkDepositGroup(wex, req);
}
case WalletApiOperation.GenerateDepositGroupTxId:
return {
@@ -1415,99 +1272,282 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
};
case WalletApiOperation.CreateDepositGroup: {
const req = codecForCreateDepositGroupRequest().decode(payload);
- return await createDepositGroup(ws, req);
+ return await createDepositGroup(wex, req);
}
case WalletApiOperation.DeleteTransaction: {
const req = codecForDeleteTransactionRequest().decode(payload);
- await deleteTransaction(ws, req.transactionId);
+ await deleteTransaction(wex, req.transactionId);
return {};
}
case WalletApiOperation.RetryTransaction: {
const req = codecForRetryTransactionRequest().decode(payload);
- await retryTransaction(ws, req.transactionId);
+ await retryTransaction(wex, req.transactionId);
return {};
}
case WalletApiOperation.SetWalletDeviceId: {
const req = codecForSetWalletDeviceIdRequest().decode(payload);
- await setWalletDeviceId(ws, req.walletDeviceId);
+ await setWalletDeviceId(wex, req.walletDeviceId);
return {};
}
- case WalletApiOperation.ListCurrencies: {
- // FIXME: Remove / change to scoped currency approach.
- return {
- trustedAuditors: [],
- trustedExchanges: [],
- };
- }
case WalletApiOperation.TestCrypto: {
- return await ws.cryptoApi.hashString({ str: "hello world" });
+ return await wex.cryptoApi.hashString({ str: "hello world" });
}
- case WalletApiOperation.ClearDb:
- await clearDatabase(ws.db.idbHandle());
+ case WalletApiOperation.ClearDb: {
+ wex.ws.clearAllCaches();
+ await clearDatabase(wex.db.idbHandle());
return {};
+ }
case WalletApiOperation.Recycle: {
throw Error("not implemented");
return {};
}
case WalletApiOperation.ExportDb: {
- const dbDump = await exportDb(ws.idb);
+ const dbDump = await exportDb(wex.ws.idb);
return dbDump;
}
+ case WalletApiOperation.ListGlobalCurrencyExchanges: {
+ const resp: ListGlobalCurrencyExchangesResponse = {
+ exchanges: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const gceList = await tx.globalCurrencyExchanges.iter().toArray();
+ for (const gce of gceList) {
+ resp.exchanges.push({
+ currency: gce.currency,
+ exchangeBaseUrl: gce.exchangeBaseUrl,
+ exchangeMasterPub: gce.exchangeMasterPub,
+ });
+ }
+ },
+ );
+ return resp;
+ }
+ case WalletApiOperation.ListGlobalCurrencyAuditors: {
+ const resp: ListGlobalCurrencyAuditorsResponse = {
+ auditors: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
+ for (const gca of gcaList) {
+ resp.auditors.push({
+ currency: gca.currency,
+ auditorBaseUrl: gca.auditorBaseUrl,
+ auditorPub: gca.auditorPub,
+ });
+ }
+ },
+ );
+ return resp;
+ }
+ case WalletApiOperation.AddGlobalCurrencyExchange: {
+ const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.globalCurrencyExchanges.add({
+ currency: req.currency,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ exchangeMasterPub: req.exchangeMasterPub,
+ });
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyExchange: {
+ const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyExchanges.delete(existingRec.id);
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.AddGlobalCurrencyAuditor: {
+ const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ await tx.globalCurrencyAuditors.add({
+ currency: req.currency,
+ auditorBaseUrl: req.auditorBaseUrl,
+ auditorPub: req.auditorPub,
+ });
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.TestingWaitTasksDone: {
+ await waitTasksDone(wex);
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyAuditor: {
+ const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyAuditors.delete(existingRec.id);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
case WalletApiOperation.ImportDb: {
const req = codecForImportDbRequest().decode(payload);
- await importDb(ws.db.idbHandle(), req.dump);
+ await importDb(wex.db.idbHandle(), req.dump);
return [];
}
case WalletApiOperation.CheckPeerPushDebit: {
const req = codecForCheckPeerPushDebitRequest().decode(payload);
- return await checkPeerPushDebit(ws, req);
+ return await checkPeerPushDebit(wex, req);
}
case WalletApiOperation.InitiatePeerPushDebit: {
const req = codecForInitiatePeerPushDebitRequest().decode(payload);
- return await initiatePeerPushDebit(ws, req);
+ return await initiatePeerPushDebit(wex, req);
}
case WalletApiOperation.PreparePeerPushCredit: {
const req = codecForPreparePeerPushCreditRequest().decode(payload);
- return await preparePeerPushCredit(ws, req);
+ return await preparePeerPushCredit(wex, req);
}
case WalletApiOperation.ConfirmPeerPushCredit: {
const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
- return await confirmPeerPushCredit(ws, req);
+ return await confirmPeerPushCredit(wex, req);
}
case WalletApiOperation.CheckPeerPullCredit: {
const req = codecForPreparePeerPullPaymentRequest().decode(payload);
- return await checkPeerPullPaymentInitiation(ws, req);
+ return await checkPeerPullPaymentInitiation(wex, req);
}
case WalletApiOperation.InitiatePeerPullCredit: {
const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
- return await initiatePeerPullPayment(ws, req);
+ return await initiatePeerPullPayment(wex, req);
}
case WalletApiOperation.PreparePeerPullDebit: {
const req = codecForCheckPeerPullPaymentRequest().decode(payload);
- return await preparePeerPullDebit(ws, req);
+ return await preparePeerPullDebit(wex, req);
}
case WalletApiOperation.ConfirmPeerPullDebit: {
const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
- return await confirmPeerPullDebit(ws, req);
+ return await confirmPeerPullDebit(wex, req);
}
case WalletApiOperation.ApplyDevExperiment: {
const req = codecForApplyDevExperiment().decode(payload);
- await applyDevExperiment(ws, req.devExperimentUri);
+ await applyDevExperiment(wex, req.devExperimentUri);
+ return {};
+ }
+ case WalletApiOperation.Shutdown: {
+ wex.ws.stop();
return {};
}
case WalletApiOperation.GetVersion: {
- return getVersion(ws);
+ return getVersion(wex);
}
case WalletApiOperation.TestingWaitTransactionsFinal:
- return await waitUntilTransactionsFinal(ws);
+ return await waitUntilAllTransactionsFinal(wex);
case WalletApiOperation.TestingWaitRefreshesFinal:
- return await waitUntilRefreshesDone(ws);
+ return await waitUntilRefreshesDone(wex);
case WalletApiOperation.TestingSetTimetravel: {
const req = codecForTestingSetTimetravelRequest().decode(payload);
setDangerousTimetravel(req.offsetMs);
- ws.workAvailable.trigger();
+ await wex.taskScheduler.reload();
+ return {};
+ }
+ case WalletApiOperation.DeleteExchange: {
+ const req = codecForDeleteExchangeRequest().decode(payload);
+ await deleteExchange(wex, req);
return {};
}
+ case WalletApiOperation.GetExchangeResources: {
+ const req = codecForGetExchangeResourcesRequest().decode(payload);
+ return await getExchangeResources(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.CanonicalizeBaseUrl: {
+ const req = codecForCanonicalizeBaseUrlRequest().decode(payload);
+ return {
+ url: canonicalizeBaseUrl(req.url),
+ };
+ }
+ case WalletApiOperation.TestingInfiniteTransactionLoop: {
+ const myDelayMs = (payload as any).delayMs ?? 5;
+ const shouldFetch = !!(payload as any).shouldFetch;
+ const doFetch = async () => {
+ while (1) {
+ const url =
+ "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000";
+ logger.info(`fetching ${url}`);
+ const res = await wex.http.fetch(url);
+ logger.info(`fetch result ${res.status}`);
+ }
+ };
+ if (shouldFetch) {
+ // In the background!
+ doFetch();
+ }
+ let loopCount = 0;
+ while (true) {
+ logger.info(`looping test write tx, iteration ${loopCount}`);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ await tx.config.put({
+ key: ConfigRecordKey.TestLoopTx,
+ value: loopCount,
+ });
+ });
+ if (myDelayMs != 0) {
+ await new Promise<void>((resolve, reject) => {
+ setTimeout(() => resolve(), myDelayMs);
+ });
+ }
+ loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1);
+ }
+ }
// default:
// assertUnreachable(operation);
}
@@ -1520,21 +1560,62 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
);
}
-export function getVersion(ws: InternalWalletState): WalletCoreVersion {
+export function getVersion(wex: WalletExecutionContext): WalletCoreVersion {
const result: WalletCoreVersion = {
+ implementationSemver: walletCoreBuildInfo.implementationSemver,
+ implementationGitHash: walletCoreBuildInfo.implementationGitHash,
hash: undefined,
- version: WALLET_CORE_API_IMPLEMENTATION_VERSION,
+ version: WALLET_CORE_API_PROTOCOL_VERSION,
exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION,
bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- devMode: false,
+ devMode: wex.ws.config.testing.devModeActive,
};
return result;
}
+export function getObservedWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: observeTalerCrypto(ws.cryptoApi, oc),
+ db: new ObservableDbAccess(ws.db, oc),
+ http: new ObservableHttpClientLibrary(ws.http, oc),
+ taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc),
+ oc,
+ };
+ return wex;
+}
+
+export function getNormalWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: ws.cryptoApi,
+ db: ws.db,
+ get http() {
+ if (ws.initCalled) {
+ return ws.http;
+ }
+ throw Error("wallet not initialized");
+ },
+ taskScheduler: ws.taskScheduler,
+ oc,
+ };
+ return wex;
+}
+
/**
* Handle a request to the wallet-core API.
*/
@@ -1544,8 +1625,56 @@ async function handleCoreApiRequest(
id: string,
payload: unknown,
): Promise<CoreApiResponse> {
+ if (operation !== WalletApiOperation.InitWallet) {
+ if (!ws.initCalled) {
+ throw Error("init must be called first");
+ }
+ // Might be lazily initialized!
+ await ws.taskScheduler.ensureRunning();
+ }
+
+ let wex: WalletExecutionContext;
+ let oc: ObservabilityContext;
+
+ const cts = CancellationToken.create();
+
+ if (ws.initCalled && ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ ws.notify({
+ type: NotificationType.RequestObservabilityEvent,
+ operation,
+ requestId: id,
+ event: evt,
+ });
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cts.token, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cts.token, oc);
+ }
+
try {
- const result = await dispatchRequestInternal(ws, operation as any, payload);
+ const start = performanceNow();
+ await ws.ensureWalletDbOpen();
+ oc.observe({
+ type: ObservabilityEventType.RequestStart,
+ });
+ const result = await dispatchRequestInternal(
+ wex,
+ cts,
+ operation as any,
+ payload,
+ );
+ const end = performanceNow();
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishSuccess,
+ durationMs: Number((end - start) / 1000n / 1000n),
+ });
return {
type: "response",
operation,
@@ -1557,6 +1686,9 @@ async function handleCoreApiRequest(
logger.info(
`finished wallet core request ${operation} with error: ${j2s(err)}`,
);
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishError,
+ });
return {
type: "error",
operation,
@@ -1566,6 +1698,35 @@ async function handleCoreApiRequest(
}
}
+export function applyRunConfigDefaults(
+ wcp?: PartialWalletRunConfig,
+): WalletRunConfig {
+ return {
+ builtin: {
+ exchanges: wcp?.builtin?.exchanges ?? [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ ],
+ },
+ features: {
+ allowHttp: wcp?.features?.allowHttp ?? false,
+ },
+ testing: {
+ denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false,
+ devModeActive: wcp?.testing?.devModeActive ?? false,
+ insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false,
+ preventThrottling: wcp?.testing?.preventThrottling ?? false,
+ skipDefaults: wcp?.testing?.skipDefaults ?? false,
+ emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false,
+ },
+ lazyTaskLoop: wcp?.lazyTaskLoop ?? false,
+ };
+}
+
+export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;
+
/**
* Public handle to a running wallet.
*/
@@ -1575,17 +1736,15 @@ export class Wallet {
private constructor(
idb: IDBFactory,
- http: HttpRequestLibrary,
+ httpFactory: HttpFactory,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
- config?: WalletConfigParameter,
) {
- this.ws = new InternalWalletStateImpl(
+ this.ws = new InternalWalletState(
idb,
- http,
+ httpFactory,
timer,
cryptoWorkerFactory,
- Wallet.getEffectiveConfig(config),
);
}
@@ -1598,61 +1757,19 @@ export class Wallet {
static async create(
idb: IDBFactory,
- http: HttpRequestLibrary,
+ httpFactory: HttpFactory,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
- config?: WalletConfigParameter,
): Promise<Wallet> {
- const w = new Wallet(idb, http, timer, cryptoWorkerFactory, config);
+ const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory);
w._client = await getClientFromWalletState(w.ws);
return w;
}
- public static defaultConfig: Readonly<WalletConfig> = {
- builtin: {
- exchanges: [
- {
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- currencyHint: "KUDOS",
- },
- ],
- },
- features: {
- allowHttp: false,
- },
- testing: {
- preventThrottling: false,
- devModeActive: false,
- insecureTrustExchange: false,
- denomselAllowLate: false,
- skipDefaults: false,
- },
- };
-
- static getEffectiveConfig(
- param?: WalletConfigParameter,
- ): Readonly<WalletConfig> {
- return deepMerge(Wallet.defaultConfig, param ?? {});
- }
-
addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
return this.ws.addNotificationListener(f);
}
- stop(): void {
- this.ws.stop();
- }
-
- async runPending(): Promise<void> {
- await this.ws.ensureWalletDbOpen();
- return runPending(this.ws);
- }
-
- async runTaskLoop(opts?: RetryLoopOpts): Promise<TaskLoopResult> {
- await this.ws.ensureWalletDbOpen();
- return runTaskLoop(this.ws, opts);
- }
-
async handleCoreApiRequest(
operation: string,
id: string,
@@ -1663,49 +1780,107 @@ export class Wallet {
}
}
+export interface DevExperimentState {
+ blockRefreshes?: boolean;
+}
+
+export class Cache<T> {
+ private map: Map<string, [AbsoluteTime, T]> = new Map();
+
+ constructor(
+ private maxCapacity: number,
+ private cacheDuration: Duration,
+ ) {}
+
+ get(key: string): T | undefined {
+ const r = this.map.get(key);
+ if (!r) {
+ return undefined;
+ }
+
+ if (AbsoluteTime.isExpired(r[0])) {
+ this.map.delete(key);
+ return undefined;
+ }
+
+ return r[1];
+ }
+
+ clear(): void {
+ this.map.clear();
+ }
+
+ put(key: string, value: T): void {
+ if (this.map.size > this.maxCapacity) {
+ this.map.clear();
+ }
+ const expiry = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ this.cacheDuration,
+ );
+ this.map.set(key, [expiry, value]);
+ }
+}
+
+/**
+ * Implementation of triggers for the wallet DB.
+ */
+class WalletDbTriggerSpec implements TriggerSpec {
+ constructor(public ws: InternalWalletState) {}
+
+ afterCommit(info: AfterCommitInfo): void {
+ if (info.mode !== "readwrite") {
+ return;
+ }
+ logger.info(
+ `in after commit callback for readwrite, modified ${j2s([
+ ...info.modifiedStores,
+ ])}`,
+ );
+ const modified = info.accessedStores;
+ if (
+ modified.has(WalletStoresV1.exchanges.storeName) ||
+ modified.has(WalletStoresV1.exchangeDetails.storeName) ||
+ modified.has(WalletStoresV1.denominations.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyExchanges.storeName)
+ ) {
+ this.ws.clearAllCaches();
+ }
+ }
+}
+
/**
* Internal state of the wallet.
*
* This ties together all the operation implementations.
*/
-class InternalWalletStateImpl implements InternalWalletState {
- /**
- * @see {@link InternalWalletState.activeLongpoll}
- */
- activeLongpoll: ActiveLongpollInfo = {};
-
+export class InternalWalletState {
cryptoApi: TalerCryptoInterface;
cryptoDispatcher: CryptoDispatcher;
- merchantInfoCache: Record<string, MerchantInfo> = {};
-
readonly timerGroup: TimerGroup;
workAvailable = new AsyncCondition();
stopped = false;
- listeners: NotificationListener[] = [];
+ private listeners: NotificationListener[] = [];
initCalled = false;
- exchangeOps: ExchangeOperations = {
- getExchangeDetails,
- fetchFreshExchange,
- };
-
- recoupOps: RecoupOperations = {
- createRecoupGroup,
- };
-
- merchantOps: MerchantOperations = {
- getMerchantInfo,
- };
+ refreshCostCache: Cache<AmountJson> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
- refreshOps: RefreshOperations = {
- createRefreshGroup,
- };
+ denomInfoCache: Cache<DenominationInfo> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
- // FIXME: Use an LRU cache here.
- private denomCache: Record<string, DenominationInfo> = {};
+ exchangeCache: Cache<ReadyExchangeSummary> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
/**
* Promises that are waiting for a particular resource.
@@ -1717,153 +1892,106 @@ class InternalWalletStateImpl implements InternalWalletState {
*/
private resourceLocks: Set<string> = new Set();
- isTaskLoopRunning: boolean = false;
+ taskScheduler: TaskScheduler = new TaskSchedulerImpl(this);
+
+ private _config: Readonly<WalletRunConfig> | undefined;
+
+ private _indexedDbHandle: IDBDatabase | undefined = undefined;
- config: Readonly<WalletConfig>;
+ private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined;
- private _db: DbAccess<typeof WalletStoresV1> | undefined = undefined;
+ private _http: HttpRequestLibrary | undefined = undefined;
get db(): DbAccess<typeof WalletStoresV1> {
- if (!this._db) {
+ if (!this._dbAccessHandle) {
+ this._dbAccessHandle = this.createDbAccessHandle(
+ CancellationToken.CONTINUE,
+ );
+ }
+ return this._dbAccessHandle;
+ }
+
+ devExperimentState: DevExperimentState = {};
+
+ clientCancellationMap: Map<string, CancellationToken.Source> = new Map();
+
+ clearAllCaches(): void {
+ this.exchangeCache.clear();
+ this.denomInfoCache.clear();
+ this.refreshCostCache.clear();
+ }
+
+ initWithConfig(newConfig: WalletRunConfig): void {
+ this._config = newConfig;
+
+ logger.info(`setting new config to ${j2s(newConfig)}`);
+
+ this._http = this.httpFactory(newConfig);
+
+ if (this.config.testing.devModeActive) {
+ this._http = new DevExperimentHttpLib(this.http);
+ }
+ }
+
+ createDbAccessHandle(
+ cancellationToken: CancellationToken,
+ ): DbAccess<typeof WalletStoresV1> {
+ if (!this._indexedDbHandle) {
throw Error("db not initialized");
}
- return this._db;
+ return new DbAccessImpl(
+ this._indexedDbHandle,
+ WalletStoresV1,
+ new WalletDbTriggerSpec(this),
+ cancellationToken,
+ );
+ }
+
+ get config(): WalletRunConfig {
+ if (!this._config) {
+ throw Error("config not initialized");
+ }
+ return this._config;
+ }
+
+ get http(): HttpRequestLibrary {
+ if (!this._http) {
+ throw Error("wallet not initialized");
+ }
+ return this._http;
}
constructor(
public idb: IDBFactory,
- public http: HttpRequestLibrary,
+ private httpFactory: HttpFactory,
public timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
- configParam: WalletConfig,
) {
this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
this.cryptoApi = this.cryptoDispatcher.cryptoApi;
this.timerGroup = new TimerGroup(timer);
- this.config = configParam;
- if (this.config.testing.devModeActive) {
- this.http = new DevExperimentHttpLib(this.http);
- }
}
async ensureWalletDbOpen(): Promise<void> {
- if (this._db) {
+ if (this._indexedDbHandle) {
return;
}
const myVersionChange = async (): Promise<void> => {
logger.info("version change requested for Taler DB");
};
- const myDb = await openTalerDatabase(this.idb, myVersionChange);
- this._db = myDb;
- }
-
- async getTransactionState(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<typeof WalletStoresV1>,
- transactionId: string,
- ): Promise<TransactionState | undefined> {
- const parsedTxId = parseTransactionIdentifier(transactionId);
- if (!parsedTxId) {
- throw Error("invalid tx identifier");
- }
- switch (parsedTxId.tag) {
- case TransactionType.Deposit: {
- const rec = await tx.depositGroups.get(parsedTxId.depositGroupId);
- if (!rec) {
- return undefined;
- }
- return computeDepositTransactionStatus(rec);
- }
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal: {
- const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId);
- if (!rec) {
- return undefined;
- }
- return computeWithdrawalTransactionStatus(rec);
- }
- case TransactionType.Payment: {
- const rec = await tx.purchases.get(parsedTxId.proposalId);
- if (!rec) {
- return;
- }
- return computePayMerchantTransactionState(rec);
- }
- case TransactionType.Refund: {
- const rec = await tx.refundGroups.get(parsedTxId.refundGroupId);
- if (!rec) {
- return undefined;
- }
- return computeRefundTransactionState(rec);
- }
- case TransactionType.PeerPullCredit:
- const rec = await tx.peerPullCredit.get(parsedTxId.pursePub);
- if (!rec) {
- return undefined;
- }
- return computePeerPullCreditTransactionState(rec);
- case TransactionType.PeerPullDebit: {
- const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId);
- if (!rec) {
- return undefined;
- }
- return computePeerPullDebitTransactionState(rec);
- }
- case TransactionType.PeerPushCredit: {
- const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId);
- if (!rec) {
- return undefined;
- }
- return computePeerPushCreditTransactionState(rec);
- }
- case TransactionType.PeerPushDebit: {
- const rec = await tx.peerPushDebit.get(parsedTxId.pursePub);
- if (!rec) {
- return undefined;
- }
- return computePeerPushDebitTransactionState(rec);
- }
- case TransactionType.Refresh: {
- const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId);
- if (!rec) {
- return undefined;
- }
- return computeRefreshTransactionState(rec);
- }
- case TransactionType.Reward: {
- const rec = await tx.rewards.get(parsedTxId.walletRewardId);
- if (!rec) {
- return undefined;
- }
- return computeRewardTransactionStatus(rec);
- }
- default:
- assertUnreachable(parsedTxId);
+ try {
+ const myDb = await openTalerDatabase(this.idb, myVersionChange);
+ this._indexedDbHandle = myDb;
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
}
}
- async getDenomInfo(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- }>,
- exchangeBaseUrl: string,
- denomPubHash: string,
- ): Promise<DenominationInfo | undefined> {
- const key = `${exchangeBaseUrl}:${denomPubHash}`;
- const cached = this.denomCache[key];
- if (cached) {
- return cached;
- }
- const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
- if (d) {
- return DenominationRecord.toDenomInfo(d);
- }
- return undefined;
- }
-
notify(n: WalletNotification): void {
- logger.trace("Notification", j2s(n));
+ logger.trace(`Notification: ${j2s(n)}`);
for (const l of this.listeners) {
const nc = JSON.parse(JSON.stringify(n));
setTimeout(() => {
@@ -1890,11 +2018,9 @@ class InternalWalletStateImpl implements InternalWalletState {
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
this.cryptoDispatcher.stop();
- for (const key of Object.keys(this.activeLongpoll)) {
- logger.trace(`cancelling active longpoll ${key}`);
- this.activeLongpoll[key].cancel();
- delete this.activeLongpoll[key];
- }
+ this.taskScheduler.shutdown().catch((e) => {
+ logger.warn(`shutdown failed: ${safeStringifyException(e)}`);
+ });
}
/**
@@ -1929,48 +2055,11 @@ class InternalWalletStateImpl implements InternalWalletState {
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
- let waiter = (this.resourceWaiters[token] ?? []).shift();
+ const waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}
}
}
}
-
- ensureTaskLoopRunning(): void {
- if (this.isTaskLoopRunning) {
- return;
- }
- runTaskLoop(this)
- .catch((e) => {
- logger.error("error running task loop");
- logger.error(`err: ${e}`);
- })
- .then(() => {
- logger.info("done running task loop");
- });
- }
-}
-
-/**
- * Take the full object as template, create a new result with all the values.
- * Use the override object to change the values in the result
- * return result
- * @param full
- * @param override
- * @returns
- */
-function deepMerge<T extends object>(full: T, override: object): T {
- const keys = Object.keys(full);
- const result = { ...full };
- for (const k of keys) {
- // @ts-ignore
- const newVal = override[k];
- if (newVal === undefined) continue;
- // @ts-ignore
- result[k] =
- // @ts-ignore
- typeof newVal === "object" ? deepMerge(full[k], newVal) : newVal;
- }
- return result;
}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts
index 97a80ec26..2a081b481 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.test.ts
+++ b/packages/taler-wallet-core/src/withdraw.test.ts
@@ -20,8 +20,8 @@ import {
DenominationRecord,
DenominationVerificationStatus,
timestampProtocolToDb,
-} from "../db.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
test("withdrawal selection bug repro", (t) => {
const amount = {
@@ -83,7 +83,6 @@ test("withdrawal selection bug repro", (t) => {
verificationStatus: DenominationVerificationStatus.Unverified,
currency: "KUDOS",
value: "KUDOS:1000" as AmountString,
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
{
denomPub: {
@@ -138,7 +137,6 @@ test("withdrawal selection bug repro", (t) => {
verificationStatus: DenominationVerificationStatus.Unverified,
value: "KUDOS:10" as AmountString,
currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
{
denomPub: {
@@ -192,7 +190,6 @@ test("withdrawal selection bug repro", (t) => {
verificationStatus: DenominationVerificationStatus.Unverified,
value: "KUDOS:5" as AmountString,
currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
{
denomPub: {
@@ -247,7 +244,6 @@ test("withdrawal selection bug repro", (t) => {
verificationStatus: DenominationVerificationStatus.Unverified,
value: "KUDOS:1" as AmountString,
currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
{
denomPub: {
@@ -305,7 +301,6 @@ test("withdrawal selection bug repro", (t) => {
value: 0,
}),
currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
{
denomPub: {
@@ -359,7 +354,6 @@ test("withdrawal selection bug repro", (t) => {
verificationStatus: DenominationVerificationStatus.Unverified,
value: "KUDOS:2" as AmountString,
currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
},
];
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
new file mode 100644
index 000000000..4a7c7873c
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,3604 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * @fileoverview Implementation of Taler withdrawals, both
+ * bank-integrated and manual.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ AgeRestriction,
+ Amount,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ AsyncFlag,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ ConfirmWithdrawalRequest,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelItem,
+ DenomSelectionState,
+ Duration,
+ EddsaPrivateKeyString,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ GetWithdrawalDetailsForAmountRequest,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PrepareBankIntegratedWithdrawalResponse,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalExchangeAccountDetails,
+ WithdrawalType,
+ addPaytoQueryParams,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codeForBankWithdrawalOperationPostResponse,
+ codecForBankWithdrawalOperationStatus,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampAbsoluteFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./denomSelection.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import { DbAccess } from "./query.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ isUnsuccessfulTransaction,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("withdraw.ts");
+
+/**
+ * Update the materialized withdrawal transaction based
+ * on the withdrawal group record.
+ */
+async function updateWithdrawalTransaction(
+ ctx: WithdrawTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {
+ const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
+ if (!wgRecord) {
+ await tx.transactions.delete(ctx.transactionId);
+ return;
+ }
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+
+ let transactionItem: Transaction;
+
+ if (
+ !wgRecord.instructedAmount ||
+ !wgRecord.denomsSel ||
+ !wgRecord.exchangeBaseUrl
+ ) {
+ // withdrawal group is in preparation, nothing to update
+ return;
+ }
+
+ if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) {
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed
+ ? true
+ : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else if (
+ wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual
+ ) {
+ checkDbInvariant(
+ wgRecord.instructedAmount !== undefined,
+ "manual withdrawal without amount can't be created",
+ );
+ checkDbInvariant(
+ wgRecord.denomsSel !== undefined,
+ "manual withdrawal without denoms can't be created",
+ );
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wgRecord.exchangeBaseUrl,
+ );
+ const plainPaytoUris =
+ exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ wgRecord.reservePub,
+ wgRecord.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: wgRecord.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else {
+ // FIXME: If this is an orphaned withdrawal for a p2p transaction, we
+ // still might want to report the withdrawal.
+ return;
+ }
+
+ if (retryRecord?.lastError) {
+ transactionItem.error = retryRecord.lastError;
+ }
+
+ await tx.transactions.put({
+ currency: Amounts.currencyOf(wgRecord.instructedAmount),
+ transactionItem,
+ exchanges: [wgRecord.exchangeBaseUrl],
+ });
+
+ // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
+}
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: WithdrawalGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<WithdrawalGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "withdrawalGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: stores },
+ async (tx) => {
+ const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeWithdrawalTransactionStatus(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.withdrawalGroups.put(res.rec);
+ await updateWithdrawalTransaction(this, tx);
+ const newTxState = computeWithdrawalTransactionStatus(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.withdrawalGroups.delete(this.withdrawalGroupId);
+ await updateWithdrawalTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ transactionLabel: "delete-transaction-withdraw",
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ if (rec) {
+ await tx.tombstones.put({
+ id:
+ TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId,
+ });
+ }
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "suspend-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
+ break;
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.PendingKyc:
+ newStatus = WithdrawalGroupStatus.SuspendedKyc;
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ newStatus = WithdrawalGroupStatus.SuspendedAml;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "abort-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.AbortedExchange;
+ break;
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ // No transition needed, but not an error
+ return TransitionResult.stay();
+ case WithdrawalGroupStatus.DialogProposed:
+ newStatus = WithdrawalGroupStatus.AbortedUserRefused;
+ break;
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ default:
+ assertUnreachable(wg.status);
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "resume-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedReady:
+ newStatus = WithdrawalGroupStatus.PendingReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ newStatus = WithdrawalGroupStatus.PendingAml;
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ newStatus = WithdrawalGroupStatus.PendingKyc;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async failTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "fail-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.FailedAbortingBank;
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+}
+
+/**
+ * Compute the DD37 transaction state of a withdrawal transaction
+ * from the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionStatus(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionState {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case WithdrawalGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.AbortingBank:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingAml:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedAml:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case WithdrawalGroupStatus.AbortedExchange:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Exchange,
+ };
+ case WithdrawalGroupStatus.AbortedBank:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Refused,
+ };
+ case WithdrawalGroupStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ };
+ }
+}
+
+/**
+ * Compute DD37 transaction actions for a withdrawal transaction
+ * based on the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionActions(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionAction[] {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.Done:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingReady:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.AbortingBank:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.DialogProposed:
+ return [TransactionAction.Abort];
+ }
+}
+
+async function processWithdrawalGroupDialogProposed(
+ ctx: WithdrawTransactionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ if (
+ withdrawalGroup.wgInfo.withdrawalType !==
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ throw new Error(
+ "processWithdrawalGroupDialogProposed called in unexpected state",
+ );
+ }
+
+ const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
+
+ const parsedUri = parseWithdrawUri(talerWithdrawUri);
+
+ checkLogicInvariant(!!parsedUri);
+
+ const wopid = parsedUri.withdrawalOperationId;
+
+ const url = new URL(
+ `withdrawal-operation/${wopid}`,
+ parsedUri.bankIntegrationApiBaseUrl,
+ );
+
+ url.searchParams.set("old_state", "pending");
+ url.searchParams.set("long_poll_ms", "30000");
+
+ const resp = await ctx.wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ // If the bank claims that the withdrawal operation is already
+ // pending, but we're still in DialogProposed, some other wallet
+ // must've completed the withdrawal, we're giving up.
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ const body = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForBankWithdrawalOperationStatus(),
+ );
+ if (body.status !== "pending") {
+ await ctx.transition({}, async (rec) => {
+ switch (rec?.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.AbortedOtherWallet;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ break;
+ }
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ *
+ * FIXME: Move into bank client.
+ */
+export async function getBankWithdrawalInfo(
+ http: HttpRequestLibrary,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse URL ${talerWithdrawUri}`);
+ }
+
+ const bankApi = new TalerBankIntegrationHttpClient(
+ uriResult.bankIntegrationApiBaseUrl,
+ http,
+ );
+
+ const { body: config } = await bankApi.getConfig();
+
+ if (!bankApi.isCompatible(config.version)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ bankProtocolVersion: config.version,
+ walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ },
+ "bank integration protocol version not compatible with wallet",
+ );
+ }
+
+ const resp = await bankApi.getWithdrawalOperationById(
+ uriResult.withdrawalOperationId,
+ );
+
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ const { body: status } = resp;
+
+ return {
+ operationId: uriResult.withdrawalOperationId,
+ apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ wireTypes: status.wire_types,
+ status: status.status,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getCandidateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
+ },
+ );
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
+ const allDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ return allDenoms
+ .filter((d) => d.currency === currency)
+ .filter((d) =>
+ isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate),
+ );
+}
+
+/**
+ * Generate a planchet for a coin index in a withdrawal group.
+ * Does not actually withdraw the coin yet.
+ *
+ * Split up so that we can parallelize the crypto, but serialize
+ * the exchange requests per reserve.
+ */
+async function processPlanchetGenerate(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ checkDbInvariant(
+ withdrawalGroup.denomsSel !== undefined,
+ "can't process uninitialized exchange",
+ );
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ let planchet = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets"] },
+ async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ },
+ );
+ if (planchet) {
+ return;
+ }
+ let ci = 0;
+ let isSkipped = false;
+ 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) {
+ maybeDenomPubHash = d.denomPubHash;
+ if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
+ isSkipped = true;
+ }
+ break;
+ }
+ ci += d.count;
+ }
+ if (isSkipped) {
+ return;
+ }
+ if (!maybeDenomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denomPubHash = maybeDenomPubHash;
+
+ const denom = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash);
+ },
+ );
+ checkDbInvariant(!!denom);
+ const r = await wex.cryptoApi.createPlanchet({
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
+ reservePriv: withdrawalGroup.reservePriv,
+ reservePub: withdrawalGroup.reservePub,
+ value: Amounts.parseOrThrow(denom.value),
+ coinIndex: coinIdx,
+ secretSeed: withdrawalGroup.secretSeed,
+ restrictAge: withdrawalGroup.restrictAge,
+ });
+ const newPlanchet: PlanchetRecord = {
+ blindingKey: r.blindingKey,
+ coinEv: r.coinEv,
+ coinEvHash: r.coinEvHash,
+ coinIdx,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ denomPubHash: r.denomPubHash,
+ planchetStatus: PlanchetStatus.Pending,
+ withdrawSig: r.withdrawSig,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ ageCommitmentProof: r.ageCommitmentProof,
+ lastError: undefined,
+ };
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (p) {
+ planchet = p;
+ return;
+ }
+ await tx.planchets.put(newPlanchet);
+ planchet = newPlanchet;
+ });
+}
+
+interface WithdrawalRequestBatchArgs {
+ coinStartIndex: number;
+
+ batchSize: number;
+}
+
+interface WithdrawalBatchResult {
+ coinIdxs: number[];
+ batchResp: ExchangeWithdrawBatchResponse;
+}
+
+// FIXME: Move to exchange API types
+enum ExchangeAmlStatus {
+ Normal = 0,
+ Pending = 1,
+ Frozen = 2,
+}
+
+async function handleKycRequired(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ resp: HttpResponse,
+ startIdx: number,
+ requestCoinIdxs: number[],
+): Promise<void> {
+ logger.info("withdrawal requires KYC");
+ const respJson = await resp.json();
+ const uuidResp = codecForWalletKycUuid().decode(respJson);
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const userType = "individual";
+ const kycInfo: KycPendingInfo = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ let kycUrl: string;
+ let amlStatus: ExchangeAmlStatus | undefined;
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return;
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ kycUrl = kycStatus.kyc_url;
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ amlStatus = kycStatus.aml_status;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+
+ await ctx.transition(
+ {
+ extraStores: ["planchets"],
+ },
+ async (wg2, tx) => {
+ if (!wg2) {
+ return TransitionResult.stay();
+ }
+ for (let i = startIdx; i < requestCoinIdxs.length; i++) {
+ const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ requestCoinIdxs[i],
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ planchet.planchetStatus = PlanchetStatus.KycRequired;
+ await tx.planchets.put(planchet);
+ }
+ if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
+ return TransitionResult.stay();
+ }
+ wg2.kycPending = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ wg2.kycUrl = kycUrl;
+ wg2.status =
+ amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
+ ? WithdrawalGroupStatus.PendingKyc
+ : amlStatus === ExchangeAmlStatus.Pending
+ ? WithdrawalGroupStatus.PendingAml
+ : amlStatus === ExchangeAmlStatus.Frozen
+ ? WithdrawalGroupStatus.SuspendedAml
+ : assertUnreachable(amlStatus);
+ return TransitionResult.transition(wg2);
+ },
+ );
+}
+
+/**
+ * Send the withdrawal request for a generated planchet to the exchange.
+ *
+ * The verification of the response is done asynchronously to enable parallelism.
+ */
+async function processPlanchetExchangeBatchRequest(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ args: WithdrawalRequestBatchArgs,
+): Promise<WithdrawalBatchResult> {
+ const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
+ logger.info(
+ `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
+ );
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+
+ const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
+ // Indices of coins that are included in the batch request
+ const requestCoinIdxs: number[] = [];
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets", "denominations"] },
+ async (tx) => {
+ for (
+ let coinIdx = args.coinStartIndex;
+ coinIdx < args.coinStartIndex + args.batchSize &&
+ coinIdx < wgContext.numPlanchets;
+ coinIdx++
+ ) {
+ const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+
+ if (!denom) {
+ logger.error("db inconsistent: denom for planchet not found");
+ continue;
+ }
+
+ const planchetReq: ExchangeWithdrawRequest = {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ };
+ batchReq.planchets.push(planchetReq);
+ requestCoinIdxs.push(coinIdx);
+ }
+ },
+ );
+
+ if (batchReq.planchets.length == 0) {
+ logger.warn("empty withdrawal batch");
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+
+ async function storeCoinError(
+ errDetail: TalerErrorDetail,
+ coinIdx: number,
+ ): Promise<void> {
+ logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = errDetail;
+ await tx.planchets.put(planchet);
+ });
+ }
+
+ // FIXME: handle individual error codes better!
+
+ const reqUrl = new URL(
+ `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
+ withdrawalGroup.exchangeBaseUrl,
+ ).href;
+
+ try {
+ const resp = await wex.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: wex.cancellationToken,
+ timeout: Duration.fromSpec({ seconds: 40 }),
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ if (resp.status === HttpStatusCode.Gone) {
+ const e = await readTalerErrorResponse(resp);
+ // FIXME: Store in place of the planchet that is actually affected!
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ const errDetail = getErrorDetailFromException(e);
+ // We don't know which coin is affected, so we store the error
+ // with the first coin of the batch.
+ await storeCoinError(errDetail, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets", "denominations"] },
+ async (tx) => {
+ const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ return;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return {
+ planchet,
+ denomInfo,
+ exchangeBaseUrl: exchangeBaseUrl,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
+ });
+
+ const { planchet, denomInfo } = d;
+
+ const planchetDenomPub = denomInfo.denomPub;
+ if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
+ }
+
+ const evSig = resp.ev_sig;
+ if (!(evSig.cipher === DenomKeyType.Rsa)) {
+ throw Error("unsupported cipher");
+ }
+
+ const denomSigRsa = await wex.cryptoApi.rsaUnblind({
+ bk: planchet.blindingKey,
+ blindedSig: evSig.blinded_rsa_signature,
+ pk: planchetDenomPub.rsa_public_key,
+ });
+
+ const isValid = await wex.cryptoApi.rsaVerify({
+ hm: planchet.coinPub,
+ pk: planchetDenomPub.rsa_public_key,
+ sig: denomSigRsa.sig,
+ });
+
+ if (!isValid) {
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
+ {},
+ "invalid signature from the exchange after unblinding",
+ );
+ await tx.planchets.put(planchet);
+ });
+ return;
+ }
+
+ let denomSig: UnblindedSignature;
+ if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
+ denomSig = {
+ cipher: planchetDenomPub.cipher,
+ rsa_signature: denomSigRsa.sig,
+ };
+ } else {
+ throw Error("unsupported cipher");
+ }
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ coinEvHash: planchet.coinEvHash,
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Withdraw,
+ coinIndex: coinIdx,
+ reservePub: withdrawalGroup.reservePub,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ },
+ sourceTransactionId: transactionId,
+ maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
+ ageCommitmentProof: planchet.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ const planchetCoinPub = planchet.coinPub;
+
+ wgContext.planchetsFinished.add(planchet.coinPub);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] },
+ async (tx) => {
+ const p = await tx.planchets.get(planchetCoinPub);
+ if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ return;
+ }
+ p.planchetStatus = PlanchetStatus.WithdrawalDone;
+ p.lastError = undefined;
+ await tx.planchets.put(p);
+ await makeCoinAvailable(wex, tx, coin);
+ },
+ );
+}
+
+/**
+ * Make sure that denominations that currently can be used for withdrawal
+ * are validated, and the result of validation is stored in the database.
+ */
+export async function updateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
+ },
+ );
+ if (!exchangeDetails) {
+ logger.error("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+ // First do a pass where the validity of candidate denominations
+ // is checked and the result is stored in the database.
+ logger.trace("getting candidate denominations");
+ const denominations = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ exchangeDetails.currency,
+ );
+ logger.trace(`got ${denominations.length} candidate denominations`);
+ const batchSize = 500;
+ let current = 0;
+
+ while (current < denominations.length) {
+ const updatedDenominations: DenominationRecord[] = [];
+ // Do a batch of batchSize
+ for (
+ let batchIdx = 0;
+ batchIdx < batchSize && current < denominations.length;
+ batchIdx++, current++
+ ) {
+ const denom = denominations[current];
+ if (
+ denom.verificationStatus === DenominationVerificationStatus.Unverified
+ ) {
+ logger.trace(
+ `Validating denomination (${current + 1}/${
+ denominations.length
+ }) signature of ${denom.denomPubHash}`,
+ );
+ let valid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ valid = true;
+ } else {
+ const res = await wex.cryptoApi.isValidDenom({
+ denom,
+ masterPub: exchangeDetails.masterPublicKey,
+ });
+ valid = res.valid;
+ }
+ logger.trace(`Done validating ${denom.denomPubHash}`);
+ if (!valid) {
+ logger.warn(
+ `Signature check for denomination h=${denom.denomPubHash} failed`,
+ );
+ denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
+ } else {
+ denom.verificationStatus =
+ DenominationVerificationStatus.VerifiedGood;
+ }
+ updatedDenominations.push(denom);
+ }
+ }
+ if (updatedDenominations.length > 0) {
+ logger.trace("writing denomination batch to db");
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ },
+ );
+ wex.ws.denomInfoCache.clear();
+ logger.trace("done with DB write");
+ }
+ }
+}
+
+/**
+ * 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 processQueryReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+ if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TaskRunResult.backoff();
+ }
+ checkDbInvariant(
+ withdrawalGroup.denomsSel !== undefined,
+ "can't process uninitialized exchange",
+ );
+ checkDbInvariant(
+ withdrawalGroup.instructedAmount !== undefined,
+ "can't process uninitialized exchange",
+ );
+
+ const reservePub = withdrawalGroup.reservePub;
+
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+
+ logger.trace(`querying reserve status via ${reserveUrl.href}`);
+
+ const resp = await wex.http.fetch(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ logger.trace(`reserve status code: HTTP ${resp.status}`);
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+
+ if (result.isError) {
+ logger.trace(
+ `got reserve status error, EC=${result.talerErrorResponse.code}`,
+ );
+ if (resp.status === HttpStatusCode.NotFound) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ let amountChanged = false;
+ if (
+ Amounts.cmp(
+ result.response.balance,
+ withdrawalGroup.denomsSel.totalWithdrawCost,
+ ) === -1
+ ) {
+ amountChanged = true;
+ }
+ console.log(`amount change ${j2s(result.response)}`);
+ console.log(
+ `amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`,
+ );
+
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);
+
+ const transitionResult = await ctx.transition(
+ {
+ extraStores: ["denominations"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TransitionResult.stay();
+ }
+ if (amountChanged) {
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ wg.denomsSel = selectWithdrawalDenominations(
+ Amounts.parseOrThrow(result.response.balance),
+ candidates,
+ );
+ }
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (transitionResult) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+/**
+ * Withdrawal context that is kept in-memory.
+ *
+ * Used to store some cached info during a withdrawal operation.
+ */
+interface WithdrawalGroupStatusInfo {
+ numPlanchets: number;
+ planchetsFinished: Set<string>;
+
+ /**
+ * Cached withdrawal group record from the database.
+ */
+ wgRecord: WithdrawalGroupRecord;
+}
+
+async function processWithdrawalGroupAbortingBank(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const wgInfo = withdrawalGroup.wgInfo;
+ if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
+ throw Error("invalid state (aborting(bank) without bank info");
+ }
+ const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
+ logger.info(`aborting withdrawal at ${abortUrl}`);
+ const abortResp = await wex.http.fetch(abortUrl, {
+ method: "POST",
+ body: {},
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`abort response status: ${abortResp.status}`);
+
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+}
+
+async function processWithdrawalGroupPendingKyc(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ const userType = "individual";
+ const kycInfo = withdrawalGroup.kycPending;
+ if (!kycInfo) {
+ throw Error("no kyc info available in pending(kyc)");
+ }
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingKyc: {
+ delete rec.kycPending;
+ delete rec.kycUrl;
+ rec.status = WithdrawalGroupStatus.PendingReady;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ return TransitionResult.stay();
+ }
+ });
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const kycUrl = kycStatus.kyc_url;
+ if (typeof kycUrl === "string") {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ rec.kycUrl = kycUrl;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Select new denominations for a withdrawal group.
+ * Necessary when denominations expired or got revoked
+ * before the withdrawal could complete.
+ */
+async function redenominateWithdrawal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return;
+ }
+ checkDbInvariant(
+ wg.denomsSel !== undefined,
+ "can't process uninitialized exchange",
+ );
+ const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
+ const exchangeBaseUrl = wg.exchangeBaseUrl;
+
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const oldSel = wg.denomsSel;
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`old denom sel: ${j2s(oldSel)}`);
+ }
+
+ const zero = Amount.zeroOfCurrency(currency);
+ let amountRemaining = zero;
+ let prevTotalCoinValue = zero;
+ let prevTotalWithdrawalCost = zero;
+ let prevHasDenomWithAgeRestriction = false;
+ let prevEarliestDepositExpiration = AbsoluteTime.never();
+ const prevDenoms: DenomSelItem[] = [];
+ let coinIndex = 0;
+ for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
+ const sel = wg.denomsSel.selectedDenoms[i];
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ sel.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error("denom in use but not not found");
+ }
+ // FIXME: Also check planchet if there was a different error or planchet already withdrawn
+ const denomOkay = isWithdrawableDenom(
+ denom,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ const numCoins = sel.count - (sel.skip ?? 0);
+ const denomValue = Amount.from(denom.value).mult(numCoins);
+ const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
+ numCoins,
+ );
+ if (denomOkay) {
+ prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
+ prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
+ denomValue,
+ denomFeeWithdraw,
+ );
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: sel.skip,
+ });
+ prevHasDenomWithAgeRestriction =
+ prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ prevEarliestDepositExpiration = AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ timestampAbsoluteFromDb(denom.stampExpireDeposit),
+ );
+ } else {
+ amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: (sel.skip ?? 0) + numCoins,
+ });
+
+ for (let j = 0; j < sel.count; j++) {
+ const ci = coinIndex + j;
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroupId,
+ ci,
+ ]);
+ if (!p) {
+ // Maybe planchet wasn't yet generated.
+ // No problem!
+ logger.info(
+ `not aborting planchet #${coinIndex}, planchet not found`,
+ );
+ continue;
+ }
+ logger.info(`aborting planchet #${coinIndex}`);
+ p.planchetStatus = PlanchetStatus.AbortedReplaced;
+ await tx.planchets.put(p);
+ }
+ }
+
+ coinIndex += sel.count;
+ }
+
+ const newSel = selectWithdrawalDenominations(
+ amountRemaining.toJson(),
+ candidates,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`new denom sel: ${j2s(newSel)}`);
+ }
+
+ const mergedSel: DenomSelectionState = {
+ selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
+ totalCoinValue: zero
+ .add(prevTotalCoinValue, newSel.totalCoinValue)
+ .toString(),
+ totalWithdrawCost: zero
+ .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
+ .toString(),
+ hasDenomWithAgeRestriction:
+ prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ AbsoluteTime.fromProtocolTimestamp(
+ newSel.earliestDepositExpiration,
+ ),
+ ),
+ ),
+ };
+ wg.denomsSel = mergedSel;
+ if (logger.shouldLogTrace()) {
+ logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
+ }
+ await tx.withdrawalGroups.put(wg);
+ },
+ );
+}
+
+async function processWithdrawalGroupPendingReady(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ checkDbInvariant(
+ withdrawalGroup.denomsSel !== undefined,
+ "can't process uninitialized exchange",
+ );
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+ }
+
+ const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
+ .map((x) => x.count)
+ .reduce((a, b) => a + b);
+
+ const wgContext: WithdrawalGroupStatusInfo = {
+ numPlanchets: numTotalCoins,
+ planchetsFinished: new Set<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ wgContext.planchetsFinished.add(p.coinPub);
+ }
+ }
+ });
+
+ // We sequentially generate planchets, so that
+ // large withdrawal groups don't make the wallet unresponsive.
+ for (let i = 0; i < numTotalCoins; i++) {
+ await processPlanchetGenerate(wex, withdrawalGroup, i);
+ }
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
+ const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
+ batchSize: maxBatchSize,
+ coinStartIndex: i,
+ });
+ let work: Promise<void>[] = [];
+ work = [];
+ for (let j = 0; j < resp.coinIdxs.length; j++) {
+ if (!resp.batchResp.ev_sigs[j]) {
+ // response may not be available when there is kyc needed
+ continue;
+ }
+ work.push(
+ processPlanchetVerifyAndStoreCoin(
+ wex,
+ wgContext,
+ resp.coinIdxs[j],
+ resp.batchResp.ev_sigs[j],
+ ),
+ );
+ }
+ await Promise.all(work);
+ }
+
+ let redenomRequired = false;
+
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus !== PlanchetStatus.Pending) {
+ continue;
+ }
+ if (!p.lastError) {
+ continue;
+ }
+ switch (p.lastError.code) {
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
+ redenomRequired = true;
+ return;
+ }
+ }
+ });
+
+ if (redenomRequired) {
+ logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`);
+ await fetchFreshExchange(wex, exchangeBaseUrl, {
+ forceUpdate: true,
+ });
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+ await redenominateWithdrawal(wex, withdrawalGroupId);
+ return TaskRunResult.backoff();
+ }
+
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ let numActive = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ctx.transition(
+ {
+ extraStores: ["coins", "coinAvailability", "planchets"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+
+ const groupPlanchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const x of groupPlanchets) {
+ switch (x.planchetStatus) {
+ case PlanchetStatus.KycRequired:
+ case PlanchetStatus.Pending:
+ numActive++;
+ break;
+ case PlanchetStatus.WithdrawalDone:
+ break;
+ }
+ if (x.lastError) {
+ numPlanchetErrors++;
+ if (numPlanchetErrors < maxReportedErrors) {
+ errorsPerCoin[x.coinIdx] = x.lastError;
+ }
+ }
+ }
+
+ if (wg.timestampFinish === undefined && numActive === 0) {
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ }
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (!res) {
+ throw Error("withdrawal group does not exist anymore");
+ }
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ if (numPlanchetErrors > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
+ {
+ errorsPerCoin,
+ numErrors: numPlanchetErrors,
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+export async function processWithdrawalGroup(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ switch (withdrawalGroup.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return await processBankRegisterReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return processQueryReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return await processReserveBankStatus(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.PendingReady:
+ // Continue with the actual withdrawal!
+ return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortingBank:
+ return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.DialogProposed:
+ return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // Nothing to do.
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(withdrawalGroup.status);
+ }
+}
+
+const AGE_MASK_GROUPS = "8:10:12:14:16:18"
+ .split(":")
+ .map((n) => parseInt(n, 10));
+
+export async function getExchangeWithdrawalInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ instructedAmount: AmountJson,
+ ageRestricted: number | undefined,
+): Promise<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {});
+
+ wex.cancellationToken.throwIfCancelled();
+
+ if (exchange.currency != instructedAmount.currency) {
+ // Specifying the amount in the conversion input currency is not yet supported.
+ // We might add support for it later.
+ throw new Error(
+ `withdrawal only supported when specifying target currency ${exchange.currency}`,
+ );
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount,
+ },
+ wex.cancellationToken,
+ );
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("getting candidate denoms");
+ const candidateDenoms = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("selecting withdrawal denoms");
+ // FIXME: Why not in a transaction?
+ const selectedDenoms = selectWithdrawalDenominations(
+ instructedAmount,
+ candidateDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ logger.trace("selection done");
+
+ if (selectedDenoms.selectedDenoms.length === 0) {
+ throw Error(
+ `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
+ instructedAmount,
+ )}`,
+ );
+ }
+
+ const exchangeWireAccounts: string[] = [];
+
+ for (const account of exchange.wireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ let versionMatch;
+ if (exchange.protocolVersionRange) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchange.protocolVersionRange,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ logger.warn(
+ `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
+ `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+ if (exchange.tosAcceptedTimestamp) {
+ if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
+ tosAccepted = true;
+ }
+ }
+
+ const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
+ if (!paytoUris) {
+ throw Error("exchange is in invalid state");
+ }
+
+ const ret: ExchangeWithdrawalDetails = {
+ earliestDepositExpiration: selectedDenoms.earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ selectedDenoms,
+ versionMatch,
+ walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ termsOfServiceAccepted: tosAccepted,
+ withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
+ withdrawalAmountRaw: Amounts.stringify(instructedAmount),
+ // TODO: remove hardcoding, this should be calculated from the denominations info
+ // force enabled for testing
+ ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction
+ ? AGE_MASK_GROUPS
+ : undefined,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+/**
+ * Get more information about a taler://withdraw URI.
+ *
+ * As side effects, the bank (via the bank integration API) is queried
+ * and the exchange suggested by the bank is ephemerally added
+ * to the wallet's list of known exchanges.
+ */
+export async function getWithdrawalDetailsForUri(
+ wex: WalletExecutionContext,
+ talerWithdrawUri: string,
+): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
+ const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
+ logger.trace(`got bank info`);
+ if (info.suggestedExchange) {
+ try {
+ // If the exchange entry doesn't exist yet,
+ // it'll be created as an ephemeral entry.
+ await fetchFreshExchange(wex, info.suggestedExchange);
+ } catch (e) {
+ // We still continued if it failed, as other exchanges might be available.
+ // We don't want to fail if the bank-suggested exchange is broken/offline.
+ logger.trace(
+ `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
+ );
+ }
+ }
+
+ const currency = Amounts.currencyOf(info.amount);
+
+ const listExchangesResp = await listExchanges(wex);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ return {
+ operationId: info.operationId,
+ confirmTransferUrl: info.confirmTransferUrl,
+ status: info.status,
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges,
+ };
+}
+
+export function augmentPaytoUrisForWithdrawal(
+ plainPaytoUris: string[],
+ reservePub: string,
+ instructedAmount: AmountLike,
+): string[] {
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(instructedAmount),
+ message: `Taler ${reservePub}`,
+ }),
+ );
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+ tx: WalletDbReadOnlyTransaction<
+ ["withdrawalGroups", "exchanges", "exchangeDetails"]
+ >,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ checkDbInvariant(
+ withdrawalGroup.instructedAmount !== undefined,
+ "can't get funding uri from uninitialized wg",
+ );
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ 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 augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+}
+
+async function getWithdrawalGroupRecordTx(
+ db: DbAccess<typeof WalletStoresV1>,
+ req: {
+ withdrawalGroupId: string;
+ },
+): Promise<WithdrawalGroupRecord | undefined> {
+ return await db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ },
+ );
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+ return { d_ms: 60000 };
+}
+
+export function getBankStatusUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+export function getBankAbortUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+async function registerReserveWithBank(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return;
+ }
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("expecting withdrawal type = bank integrated");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+ const reqBody = {
+ reserve_pub: withdrawalGroup.reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ };
+ logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
+ const httpResp = await wex.http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codeForBankWithdrawalOperationPostResponse(),
+ );
+
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ );
+ r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
+ return TransitionResult.transition(r);
+ });
+}
+
+async function transitionBankAborted(
+ ctx: WithdrawTransactionContext,
+): Promise<TaskRunResult> {
+ logger.info("bank aborted the withdrawal");
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ return TransitionResult.transition(r);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processBankRegisterReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+
+ const statusResp = await wex.http.fetch(url.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ // FIXME: Put confirm transfer URL in the DB!
+
+ await registerReserveWithBank(wex, withdrawalGroupId);
+ return TaskRunResult.progress();
+}
+
+async function processReserveBankStatus(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const bankStatusUrl = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ bankStatusUrl.searchParams.set("long_poll_ms", "30000");
+
+ logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
+ const statusResp = await wex.http.fetch(bankStatusUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `long-polling for withdrawal operation returned status ${statusResp.status}`,
+ );
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`response body: ${j2s(status)}`);
+ }
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ if (!status.transfer_done) {
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const transitionInfo = await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ // Re-check reserve status within transaction
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ if (status.transfer_done) {
+ logger.info("withdrawal: transfer confirmed by bank.");
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.PendingQueryingStatus;
+ return TransitionResult.transition(r);
+ } else {
+ return TransitionResult.stay();
+ }
+ });
+
+ if (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+export interface PrepareCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transactionId: string;
+ creationInfo?: {
+ amount: AmountJson;
+ canonExchange: string;
+ };
+}
+
+async function getInitialDenomsSelection(
+ wex: WalletExecutionContext,
+ exchange: string,
+ amount: AmountJson,
+ forcedDenoms: ForcedDenomSel | undefined,
+): Promise<DenomSelectionState> {
+ const currency = Amounts.currencyOf(amount);
+ await updateWithdrawalDenoms(wex, exchange);
+ const denoms = await getCandidateWithdrawalDenoms(wex, exchange, currency);
+
+ if (forcedDenoms) {
+ logger.warn("using forced denom selection");
+ const initialDenomSel = selectForcedWithdrawalDenominations(
+ amount,
+ denoms,
+ forcedDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ return initialDenomSel;
+ } else {
+ const initialDenomSel = selectWithdrawalDenominations(
+ amount,
+ denoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ return initialDenomSel;
+ }
+}
+
+export async function internalPrepareCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount?: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({}));
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ const exchangeBaseUrl = args.exchangeBaseUrl;
+ const amount = args.amount;
+
+ let withdrawalGroupId: string;
+
+ if (args.forcedWithdrawalGroupId) {
+ withdrawalGroupId = args.forcedWithdrawalGroupId;
+ const wgId = withdrawalGroupId;
+ const existingWg = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(wgId);
+ },
+ );
+
+ if (existingWg) {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWg.withdrawalGroupId,
+ });
+ return { withdrawalGroup: existingWg, transactionId };
+ }
+ } else {
+ withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ let initialDenomSel: DenomSelectionState | undefined;
+ const denomSelUid = encodeCrock(getRandomBytes(16));
+
+ if (amount !== undefined) {
+ initialDenomSel = await getInitialDenomsSelection(
+ wex,
+ exchangeBaseUrl,
+ amount,
+ args.forcedDenomSel,
+ );
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ denomSelUid,
+ // next fields will be undefined if exchange or amount is not specified
+ denomsSel: initialDenomSel,
+ exchangeBaseUrl: exchangeBaseUrl,
+ instructedAmount:
+ amount === undefined ? undefined : Amounts.stringify(amount),
+ rawWithdrawalAmount: initialDenomSel?.totalWithdrawCost,
+ effectiveWithdrawalAmount: initialDenomSel?.totalCoinValue,
+ // end of optional fields
+ timestampStart: timestampPreciseToDb(now),
+ secretSeed,
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ status: args.reserveStatus,
+ withdrawalGroupId,
+ restrictAge: args.restrictAge,
+ senderWire: undefined,
+ timestampFinish: undefined,
+ wgInfo: args.wgInfo,
+ };
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+
+ return {
+ withdrawalGroup,
+ transactionId,
+ creationInfo: !amount
+ ? undefined
+ : {
+ amount,
+ canonExchange: exchangeBaseUrl,
+ },
+ };
+}
+
+export interface PerformCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transitionInfo: TransitionInfo | undefined;
+
+ /**
+ * Notification for the exchange state transition.
+ *
+ * Should be emitted after the transaction has succeeded.
+ */
+ exchangeNotif: WalletNotification | undefined;
+}
+
+export async function internalPerformCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["withdrawalGroups", "reserves", "exchanges"]
+ >,
+ prep: PrepareCreateWithdrawalGroupResult,
+): Promise<PerformCreateWithdrawalGroupResult> {
+ const { withdrawalGroup } = prep;
+ const existingWg = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (existingWg) {
+ return {
+ withdrawalGroup: existingWg,
+ exchangeNotif: undefined,
+ transitionInfo: undefined,
+ };
+ }
+ await tx.withdrawalGroups.add(withdrawalGroup);
+ await tx.reserves.put({
+ reservePub: withdrawalGroup.reservePub,
+ reservePriv: withdrawalGroup.reservePriv,
+ });
+
+ if (!prep.creationInfo) {
+ return {
+ withdrawalGroup,
+ transitionInfo: undefined,
+ exchangeNotif: undefined,
+ };
+ }
+ const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange);
+ if (exchange) {
+ exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ await tx.exchanges.put(exchange);
+ }
+
+ const oldTxState = {
+ major: TransactionMajorState.None,
+ minor: undefined,
+ };
+ const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
+ const transitionInfo = {
+ oldTxState,
+ newTxState,
+ };
+
+ const exchangeUsedRes = await markExchangeUsed(
+ wex,
+ tx,
+ prep.creationInfo.canonExchange,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ withdrawalGroup,
+ transitionInfo,
+ exchangeNotif: exchangeUsedRes.notif,
+ };
+}
+
+/**
+ * Create a withdrawal group.
+ *
+ * If a forcedWithdrawalGroupId is given and a
+ * withdrawal group with this ID already exists,
+ * the existing one is returned. No conflict checking
+ * of the other arguments is done in that case.
+ */
+export async function internalCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ exchangeBaseUrl: string;
+ amount?: AmountJson;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ prep.withdrawalGroup.withdrawalGroupId,
+ );
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
+ await updateWithdrawalTransaction(ctx, tx);
+ return res;
+ },
+ );
+ if (res.exchangeNotif) {
+ wex.ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(wex, transactionId, res.transitionInfo);
+ return res.withdrawalGroup;
+}
+
+export async function prepareBankIntegratedWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange?: string;
+ },
+): Promise<PrepareBankIntegratedWithdrawalResponse> {
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri);
+ return {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ info,
+ };
+ }
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+
+ const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri);
+
+ const exchangeBaseUrl =
+ req.selectedExchange ?? withdrawInfo.suggestedExchange;
+ if (!exchangeBaseUrl) {
+ return { info };
+ }
+
+ /**
+ * Withdrawal group without exchange and amount
+ * this is an special case when the user haven't yet
+ * choose. We are still tracking this object since the state
+ * can change from the bank side or another wallet with the
+ * same URI
+ */
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ exchangeBaseUrl,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ bankInfo: {
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ wireTypes: withdrawInfo.wireTypes,
+ },
+ },
+ reserveStatus: WithdrawalGroupStatus.DialogProposed,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId: ctx.transactionId,
+ info,
+ };
+}
+
+export async function confirmWithdrawal(
+ wex: WalletExecutionContext,
+ req: ConfirmWithdrawalRequest,
+): Promise<void> {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag !== TransactionType.Withdrawal) {
+ throw Error("invalid withdrawal transaction ID");
+ }
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error("withdrawal group not found");
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType !==
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("not a bank integrated withdrawal");
+ }
+
+ const selectedExchange = req.exchangeBaseUrl;
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+
+ const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
+ const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl;
+
+ /**
+ * The only reasong this to be undefined is because it is an old wallet
+ * database before adding the wireType field was added
+ */
+ let wtypes: string[];
+ if (withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined) {
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ talerWithdrawUri,
+ );
+ wtypes = withdrawInfo.wireTypes;
+ } else {
+ wtypes = withdrawalGroup.wgInfo.bankInfo.wireTypes;
+ }
+
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ wtypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: Amounts.parseOrThrow(req.amount),
+ },
+ wex.cancellationToken,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ const initalDenoms = await getInitialDenomsSelection(
+ wex,
+ req.exchangeBaseUrl,
+ Amounts.parseOrThrow(req.amount),
+ req.forcedDenomSel,
+ );
+
+ ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.exchangeBaseUrl = req.exchangeBaseUrl;
+ rec.instructedAmount = req.amount;
+ rec.denomsSel = initalDenoms;
+ rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost;
+ rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue;
+ rec.restrictAge = req.restrictAge;
+
+ rec.wgInfo = {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri,
+ confirmUrl: confirmUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ wireTypes: wtypes,
+ },
+ };
+
+ rec.status = WithdrawalGroupStatus.PendingRegisteringBank;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ throw Error("unable to confirm withdrawal in current state");
+ }
+ });
+
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+/**
+ * Accept a bank-integrated withdrawal.
+ *
+ * Before returning, the wallet tries to register the reserve with the bank.
+ *
+ * Thus after this call returns, the withdrawal operation can be confirmed
+ * with the bank.
+ *
+ * @deprecated in favor of prepare/accept
+ */
+export async function acceptWithdrawalFromUri(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<AcceptWithdrawalResponse> {
+ const selectedExchange = req.selectedExchange;
+ logger.info(
+ `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
+ );
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ reservePub: existingWithdrawalGroup.reservePub,
+ confirmTransferUrl: url,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ wireTypes: withdrawInfo.wireTypes,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ await waitWithdrawalRegistered(wex, ctx);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId: ctx.transactionId,
+ };
+}
+
+async function internalWaitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+ withdrawalNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ retryRec: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+
+ if (!withdrawalRec) {
+ throw Error("withdrawal not found anymore");
+ }
+
+ switch (withdrawalRec.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default: {
+ if (retryRec) {
+ if (retryRec.lastError) {
+ throw TalerError.fromUncheckedDetail(retryRec.lastError);
+ } else {
+ throw Error("withdrawal unexpectedly pending");
+ }
+ }
+ }
+ }
+
+ await withdrawalNotifFlag.wait();
+ withdrawalNotifFlag.reset();
+ }
+}
+
+async function waitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+): Promise<void> {
+ // FIXME: Doesn't support cancellation yet
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ withdrawalNotifFlag.raise();
+ }
+ });
+
+ try {
+ const res = await internalWaitWithdrawalRegistered(
+ wex,
+ ctx,
+ withdrawalNotifFlag,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function fetchAccount(
+ wex: WalletExecutionContext,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub: string | undefined,
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails> {
+ let paytoUri: string;
+ let transferAmount: AmountString | undefined = undefined;
+ let currencySpecification: CurrencySpecification | undefined = undefined;
+ if (acct.conversion_url != null) {
+ const reqUrl = new URL("cashin-rate", acct.conversion_url);
+ reqUrl.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(instructedAmount),
+ );
+ const httpResp = await wex.http.fetch(reqUrl.href, {
+ cancellationToken,
+ });
+ const respOrErr = await readSuccessResponseJsonOrErrorCode(
+ httpResp,
+ codecForCashinConversionResponse(),
+ );
+ if (respOrErr.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: respOrErr.talerErrorResponse,
+ };
+ }
+ const resp = respOrErr.response;
+ paytoUri = acct.payto_uri;
+ transferAmount = resp.amount_debit;
+ const configUrl = new URL("config", acct.conversion_url);
+ const configResp = await wex.http.fetch(configUrl.href, {
+ cancellationToken,
+ });
+ const configRespOrError = await readSuccessResponseJsonOrErrorCode(
+ configResp,
+ codecForConversionBankConfig(),
+ );
+ if (configRespOrError.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: configRespOrError.talerErrorResponse,
+ };
+ }
+ const configParsed = configRespOrError.response;
+ currencySpecification = configParsed.fiat_currency_specification;
+ } else {
+ paytoUri = acct.payto_uri;
+ transferAmount = Amounts.stringify(instructedAmount);
+ }
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler ${reservePub}`,
+ });
+ }
+ const acctInfo: WithdrawalExchangeAccountDetails = {
+ status: "ok",
+ paytoUri,
+ transferAmount,
+ bankLabel: acct.bank_label,
+ priority: acct.priority,
+ currencySpecification,
+ creditRestrictions: acct.credit_restrictions,
+ };
+ if (transferAmount != null) {
+ acctInfo.transferAmount = transferAmount;
+ }
+ return acctInfo;
+}
+
+/**
+ * Gather information about bank accounts that can be used for
+ * withdrawals. This includes accounts that are in a different
+ * currency and require conversion.
+ */
+async function fetchWithdrawalAccountInfo(
+ wex: WalletExecutionContext,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ wex,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ cancellationToken,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ withdrawalAccounts.sort((x1, x2) => {
+ // Accounts without explicit priority have prio 0.
+ const n1 = x1.priority ?? 0;
+ const n2 = x2.priority ?? 0;
+ return Math.sign(n2 - n1);
+ });
+ return withdrawalAccounts;
+}
+
+/**
+ * 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(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ forceReservePriv?: EddsaPrivateKeyString;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+
+ let reserveKeyPair: EddsaKeypair;
+ if (req.forceReservePriv) {
+ const pubResp = await wex.cryptoApi.eddsaGetPublic({
+ priv: req.forceReservePriv,
+ });
+
+ reserveKeyPair = {
+ priv: req.forceReservePriv,
+ pub: pubResp.pub,
+ };
+ } else {
+ reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankManual,
+ exchangeCreditAccounts: withdrawalAccountsList,
+ },
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair,
+ });
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ const exchangePaytoUris = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId: ctx.transactionId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitWithdrawalFinal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ withdrawalNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ withdrawalNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitWithdrawalFinal(
+ ctx: WithdrawTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ };
+ },
+ );
+ const { wg } = res;
+ if (!wg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Transaction is final
+ return;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
+
+export async function getWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const clientCancelKey = req.clientCancellationId
+ ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}`
+ : undefined;
+ if (clientCancelKey) {
+ const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
+ if (prevCts) {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Cancelling previous key ${clientCancelKey}`,
+ });
+ prevCts.cancel();
+ } else {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `No previous key ${clientCancelKey}`,
+ });
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ wex.ws.clientCancellationMap.set(clientCancelKey, cts);
+ }
+ try {
+ return await internalGetWithdrawalDetailsForAmount(wex, req);
+ } finally {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ if (clientCancelKey && !cts.token.isCancelled) {
+ wex.ws.clientCancellationMap.delete(clientCancelKey);
+ }
+ }
+}
+
+async function internalGetWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ req.exchangeBaseUrl,
+ Amounts.parseOrThrow(req.amount),
+ req.restrictAge,
+ );
+ let numCoins = 0;
+ for (const x of wi.selectedDenoms.selectedDenoms) {
+ numCoins += x.count;
+ }
+ const resp: WithdrawalDetailsForAmount = {
+ amountRaw: req.amount,
+ amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
+ paytoUris: wi.exchangePaytoUris,
+ tosAccepted: wi.termsOfServiceAccepted,
+ ageRestrictionOptions: wi.ageRestrictionOptions,
+ withdrawalAccountsList: wi.exchangeCreditAccountDetails,
+ numCoins,
+ scopeInfo: wi.scopeInfo,
+ };
+ return resp;
+}
diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json
index 663a4dd98..7a1a0fcce 100644
--- a/packages/taler-wallet-core/tsconfig.json
+++ b/packages/taler-wallet-core/tsconfig.json
@@ -15,7 +15,7 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
- "strictPropertyInitialization": false,
+ "strictPropertyInitialization": true,
"outDir": "lib",
"noImplicitAny": true,
"noImplicitThis": true,
@@ -33,5 +33,5 @@
"path": "../taler-util/"
}
],
- "include": ["src/**/*", "src/*.json", "../taler-util/src/bank-api-client.ts"]
+ "include": ["src/**/*", "src/*.json"]
}