Compare commits

...

50 Commits

Author SHA1 Message Date
70fca92e78
[age-withdraw] WiP: first types and adjustments 2023-08-15 13:48:37 +02:00
819949d7f2
Merge branch 'master' into age-withdraw 2023-08-15 13:47:29 +02:00
Krystian Baran
adb0e70f15
Translated using Weblate (Italian)
Currently translated at 0.7% (4 of 531 strings)

Translation: GNU Taler/Merchant Backoffice
Translate-URL: https://weblate.taler.net/projects/gnu-taler/merchant-backoffice/it/
2023-08-15 09:28:43 +02:00
ed33c4dfce
Translated using Weblate (German)
Currently translated at 0.1% (1 of 531 strings)

Translation: GNU Taler/Merchant Backoffice
Translate-URL: https://weblate.taler.net/projects/gnu-taler/merchant-backoffice/de/
2023-08-15 09:28:42 +02:00
Krystian Baran
73ad4154f6
Translated using Weblate (Italian)
Currently translated at 24.1% (22 of 91 strings)

Translation: GNU Taler/Taler Bank SPA
Translate-URL: https://weblate.taler.net/projects/gnu-taler/taler-bank-spa/it/
2023-08-15 09:28:42 +02:00
Krystian Baran
58e00418cf
Translated using Weblate (Italian)
Currently translated at 2.3% (9 of 379 strings)

Translation: GNU Taler/webextensions
Translate-URL: https://weblate.taler.net/projects/gnu-taler/webextensions/it/
2023-08-15 09:28:42 +02:00
Javier Sepulveda
598a8ec8df
Translated using Weblate (Spanish)
Currently translated at 52.9% (281 of 531 strings)

Translation: GNU Taler/Merchant Backoffice
Translate-URL: https://weblate.taler.net/projects/gnu-taler/merchant-backoffice/es/
2023-08-13 12:14:14 +02:00
Javier Sepulveda
2e68d21e36
Translated using Weblate (Spanish)
Currently translated at 100.0% (379 of 379 strings)

Translation: GNU Taler/webextensions
Translate-URL: https://weblate.taler.net/projects/gnu-taler/webextensions/es/
2023-08-13 12:14:14 +02:00
d59a23885e
anastasis: policy discovery CLI 2023-08-10 15:24:43 +02:00
e6c0689806
embedded: expose anastasis reducer for testing 2023-08-09 19:43:36 +02:00
Iván Ávalos
d33b70b069
wallet-core-embedded: added Anastasis policy discovery 2023-08-07 23:47:22 +02:00
Sebastian
a204105b5b
show advance mode in settings view 2023-08-07 08:14:46 -03:00
Sebastian
b1cea84ca8
show next expiration 2023-08-07 08:14:46 -03:00
Sebastian
7d53aa2755
show simple order creation unless advance mode is selected 2023-08-07 08:14:46 -03:00
Sebastian
ef148b1501
use stringify taler util 2023-08-07 08:14:45 -03:00
Sebastian
9f776d3fb0
fix date query parameter 2023-08-07 08:14:45 -03:00
Sebastian
7d1621767c
ui settings view 2023-08-07 08:14:44 -03:00
Sebastian
8eb0183c78
fix: support for empty strings 2023-08-07 08:14:43 -03:00
Sebastian
37d0f9438e
accesstoken in memory and better login when switching between accounts 2023-08-07 08:14:43 -03:00
44aeaba7b4
wallet-core: introduce tiny digits 2023-08-05 23:56:07 +02:00
0547df9538
-remove logging 2023-08-05 23:36:32 +02:00
6286699f26
-validation 2023-08-05 23:34:37 +02:00
308a4282cb
wallet-core: mock implementation of GetCurrencyInfo 2023-08-05 23:34:37 +02:00
L
951d1b66fa
Added translation using Weblate (Dutch) 2023-08-05 13:05:17 +02:00
Sebastian
acf110dd78
added new continue after wire transfer confirmed 2023-08-04 10:10:30 -03:00
Sebastian
6f4548c892
fix signature for aml decision 2023-08-04 09:09:00 -03:00
c12a366d49
adjustment of age-commitment and -proof generation
age-withdraw requires that the public keys in the age groups that
are too large for the commitment are derived from a published public
key.
2023-08-03 21:47:59 +02:00
cf49af2bb9
harness: allow overriding the test timeout via env variable 2023-08-03 21:44:43 +02:00
77ea209ddb
Merge branch 'master' into age-withdraw 2023-08-03 19:35:52 +02:00
2b9faf3d4e
derive the age commitment from seed 2023-08-03 19:28:10 +02:00
ee47aa4837
fix integration tests
Instead of using the deprecated runUntilDone, we now wait
for specific notifications.

The old way doesn't work, since p2p push transactions are not considered
done until the counterparty has accepted the payment.
2023-08-03 19:24:04 +02:00
57e86b759e
rename tip->reward in URI 2023-08-03 19:03:24 +02:00
0b5d0cdc71
fix version substitution 2023-08-03 18:58:44 +02:00
3d7df63c59
fix taleruri test failure 2023-08-03 18:40:25 +02:00
fdbd55d2bd
-towards tip->reward rename 2023-08-03 18:35:07 +02:00
0fe4840ca2
-fix Makefile 2023-08-03 13:20:38 +02:00
5422f679b3 -fix Makefile 2023-08-03 13:18:07 +02:00
475fdb502b
organize imports 2023-08-02 16:27:59 +02:00
Sebastian
21f678ead9
fix #7800 2023-08-02 10:59:19 -03:00
Sebastian
72b8a70da2
fix #7830 2023-08-02 10:41:14 -03:00
Sebastian
60929c34f3
use local port 2023-08-02 10:30:40 -03:00
Sebastian
22a3017d52
removing import assert since it breaks with linaria 2023-08-02 09:49:14 -03:00
Sebastian
efed6b32c5
fix #7717 2023-08-02 07:40:28 -03:00
e3460de331
-comments 2023-07-31 15:57:12 +02:00
Iván Ávalos
0ac5dba088
taler-wallet-embedded: add argon2id test 2023-07-26 12:09:17 -06:00
Iván Ávalos
0f6310bba4
wallet-core-embedded: add initial states to Anastasis handler 2023-07-26 12:09:17 -06:00
Iván Ávalos
5a91ec2da6
Move anastasis CLI into separate package 2023-07-26 12:09:17 -06:00
72a0da7bbe
anastasis-core: tsconfig 2023-07-26 12:09:17 -06:00
Iván Ávalos
e0f32dc899
taler-util: import hash-wasm only when building for Node 2023-07-26 12:09:17 -06:00
Iván Ávalos
ef51ba983f
WIP: initial work for Anastasis in qtart 2023-07-26 12:09:17 -06:00
119 changed files with 4195 additions and 969 deletions

View File

@ -20,6 +20,7 @@ dist:
$(git-archive-all) \
--include ./configure \
--include ./packages/taler-wallet-cli/configure \
--include ./packages/anastasis-cli/configure \
--include ./packages/demobank-ui/configure \
--include ./packages/taler-harness/configure \
--include ./packages/merchant-backoffice-ui/configure \
@ -121,15 +122,17 @@ install:
pnpm install --frozen-lockfile
pnpm run compile
make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps
make -C packages/demobank-ui TOPLEVEL=yes install-nodeps
make -C packages/merchant-backoffice-ui TOPLEVEL=yes install-nodeps
make -C packages/aml-backoffice-ui TOPLEVEL=yes install-nodeps
.PHONY: install-tools
# Install taler-wallet-cli and taler-harness
# Install taler-wallet-cli, anastasis-cli and taler-harness
install-tools:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/taler-harness...
pnpm run --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/taler-harness... compile
pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness...
pnpm run --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... compile
make -C packages/taler-wallet-cli TOPLEVEL=yes install-nodeps
make -C packages/anastasis-cli TOPLEVEL=yes install-nodeps
make -C packages/taler-harness TOPLEVEL=yes install-nodeps

View File

@ -27,6 +27,7 @@ copy_configure() {
our_configure=build-system/taler-build-scripts/configure
copy_configure "$our_configure" ./configure
copy_configure "$our_configure" ./packages/taler-wallet-cli/configure
copy_configure "$our_configure" ./packages/anastasis-cli/configure
copy_configure "$our_configure" ./packages/demobank-ui/configure
copy_configure "$our_configure" ./packages/merchant-backoffice-ui/configure
copy_configure "$our_configure" ./packages/taler-harness/configure

View File

@ -61,17 +61,20 @@ export function buildQuerySignature(key: SigningKey): string {
return encodeCrock(eddsaSign(sigBlob, key));
}
export function buildDecisionSignature(
key: SigningKey,
decision: AmlExchangeBackend.AmlDecision,
): string {
const zero = new Uint8Array(new ArrayBuffer(64))
const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION)
.put(hash(stringToBytes(decision.justification)))
// .put(timestampRoundedToBuffer(decision.decision_time))
//TODO: new need the null terminator, also in the exchange
.put(hash(stringToBytes(decision.justification)))//check null
.put(timestampRoundedToBuffer(decision.decision_time))
.put(amountToBuffer(decision.new_threshold))
.put(decodeCrock(decision.h_payto))
// .put(hash(stringToBytes(decision.kyc_requirements)))
.put(zero) //kyc_requirement
.put(bufferForUint32(decision.new_state))
.build();

View File

@ -85,7 +85,6 @@ export function useCases(
const records = !afterData
? []
: ((afterData ?? lastAfter).data ?? { records: [] }).records;
console.log("afterdata", afterData, lastAfter, records)
if (loadingAfter) return { loading: true, data: { records } };
if (afterData) {
return { ok: true, data: { records }, ...pagination };

View File

@ -38,7 +38,7 @@ export function NewFormEntry({
fullName: "loggedIn_user_fullname",
when: AbsoluteTime.now(),
state: AmlExchangeBackend.AmlState.pending,
threshold: Amounts.parseOrThrow("ARS:1000"),
threshold: Amounts.parseOrThrow("KUDOS:1000"),
};
const api = useAmlCasesAPI()

View File

@ -0,0 +1,41 @@
# This Makefile has been placed in the public domain.
ifeq ($(TOPLEVEL), yes)
$(info top-level build)
-include ../../.config.mk
else
$(info package-level build)
-include ../../.config.mk
-include .config.mk
endif
$(info prefix is $(prefix))
all:
@echo use 'make install' to build and install anastasis-cli
ifndef prefix
.PHONY: warn-noprefix install
warn-noprefix:
@echo "no prefix configured, did you run ./configure?"
install: warn-noprefix
else
install_target = $(prefix)/lib/anastasis-cli
.PHONY: install install-nodeps deps
install-nodeps:
./build-node.mjs
install -d $(prefix)/bin
install -d $(install_target)/bin
install -d $(install_target)/node_modules/anastasis-cli
install -d $(install_target)/node_modules/anastasis-cli/bin
install -d $(install_target)/node_modules/anastasis-cli/dist
install ./dist/anastasis-cli-bundled.cjs $(install_target)/node_modules/anastasis-cli/dist/
install ./dist/anastasis-cli-bundled.cjs.map $(install_target)/node_modules/anastasis-cli/dist/
install ./bin/anastasis-cli.mjs $(install_target)/node_modules/anastasis-cli/bin/
ln -sf $(install_target)/node_modules/anastasis-cli/bin/anastasis-cli.mjs $(prefix)/bin/anastasis-cli
deps:
pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-cli...
install:
$(MAKE) deps
$(MAKE) install-nodeps
endif

View File

@ -0,0 +1,4 @@
# anastasis-cli
This package provides `anastasis-cli`, the command-line interface for the
Anastasis backup system.

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
/*
This file is part of GNU Taler
(C) 2022 Taler Systems SA
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/>
*/
import { reducerCliMain } from '../dist/anastasis-cli-bundled.cjs';
reducerCliMain();

View File

@ -0,0 +1,70 @@
#!/usr/bin/env node
/*
This file is part of GNU Taler
(C) 2022 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 esbuild from "esbuild";
import path from "path";
import fs from "fs";
const BASE = process.cwd();
let GIT_ROOT = BASE;
while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
GIT_ROOT = path.join(GIT_ROOT, "../");
}
if (GIT_ROOT === "/") {
console.log("not found");
process.exit(1);
}
const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
function git_hash() {
const rev = fs
.readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
.toString()
.trim()
.split(/.*[: ]/)
.slice(-1)[0];
if (rev.indexOf("/") === -1) {
return rev;
} else {
return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim();
}
}
export const buildConfig = {
entryPoints: ["src/index.ts"],
outfile: "dist/anastasis-cli-bundled.cjs",
bundle: true,
minify: false,
target: ["es2020"],
format: "cjs",
platform: "node",
sourcemap: true,
inject: ["src/import-meta-url.js"],
define: {
__VERSION__: `"${_package.version}"`,
__GIT_HASH__: `"${GIT_HASH}"`,
["import.meta.url"]: "import_meta_url",
},
};
esbuild.build(buildConfig).catch((e) => {
console.log(e);
process.exit(1);
});

View File

@ -0,0 +1,44 @@
{
"name": "@gnu-taler/anastasis-cli",
"version": "0.0.1",
"description": "",
"engines": {
"node": ">=0.18.0"
},
"repository": {
"type": "git",
"url": "git://git.taler.net/wallet-core.git"
},
"author": "Florian Dold",
"license": "GPL-3.0",
"bin": {
"anastasis-cli": "./bin/anastasis-cli.mjs"
},
"type": "module",
"scripts": {
"compile": "tsc --build && ./build-node.mjs",
"test": "tsc",
"clean": "rimraf lib dist tsconfig.tsbuildinfo",
"pretty": "prettier --write src"
},
"files": [
"AUTHORS",
"README",
"COPYING",
"bin/",
"dist/node",
"src/"
],
"devDependencies": {
"@types/node": "^18.11.17",
"prettier": "^2.8.8",
"rimraf": "^3.0.2",
"typedoc": "^0.24.8",
"typescript": "^5.1.3"
},
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/anastasis-core": "workspace:*",
"tslib": "^2.5.3"
}
}

View File

@ -0,0 +1,2 @@
// Helper to make 'import.meta.url' available in esbuild-bundled code as well.
export const import_meta_url = require("url").pathToFileURL(__filename);

View File

@ -0,0 +1,87 @@
import { clk } from "@gnu-taler/taler-util/clk";
import {
discoverPolicies,
getBackupStartState,
getRecoveryStartState,
reduceAction,
} from "@gnu-taler/anastasis-core";
import fs from "fs";
import { j2s } from "@gnu-taler/taler-util";
export const reducerCli = clk.program("anastasis-cli", {
help: "Command line interface for Anastasis.",
});
reducerCli
.subcommand("reducer", "reduce", {
help: "Run the anastasis reducer",
})
.flag("initBackup", ["-b", "--backup"])
.flag("initRecovery", ["-r", "--restore"])
.maybeOption("argumentsJson", ["-a", "--arguments"], clk.STRING)
.maybeArgument("action", clk.STRING)
.maybeArgument("stateFile", clk.STRING)
.action(async (x) => {
if (x.reducer.initBackup) {
console.log(JSON.stringify(await getBackupStartState()));
return;
} else if (x.reducer.initRecovery) {
console.log(JSON.stringify(await getRecoveryStartState()));
return;
}
const action = x.reducer.action;
if (!action) {
console.log("action required");
return;
}
let lastState: any;
if (x.reducer.stateFile) {
const s = fs.readFileSync(x.reducer.stateFile, { encoding: "utf-8" });
lastState = JSON.parse(s);
} else {
const s = await read(process.stdin);
lastState = JSON.parse(s);
}
let args: any;
if (x.reducer.argumentsJson) {
args = JSON.parse(x.reducer.argumentsJson);
} else {
args = {};
}
const nextState = await reduceAction(lastState, action, args);
console.log(JSON.stringify(nextState));
});
reducerCli
.subcommand("discover", "discover", {
help: "Run the anastasis reducer",
})
.maybeArgument("stateFile", clk.STRING)
.action(async (args) => {
let lastState: any;
if (args.discover.stateFile) {
const s = fs.readFileSync(args.discover.stateFile, { encoding: "utf-8" });
lastState = JSON.parse(s);
} else {
const s = await read(process.stdin);
lastState = JSON.parse(s);
}
const res = await discoverPolicies(lastState);
console.log(j2s(res));
});
async function read(stream: NodeJS.ReadStream): Promise<string> {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
export function reducerCliMain() {
reducerCli.run();
}

View File

@ -0,0 +1,33 @@
{
"compileOnSave": true,
"compilerOptions": {
"composite": true,
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "Node16",
"sourceMap": true,
"lib": ["es6"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"strictPropertyInitialization": false,
"outDir": "lib",
"noImplicitAny": true,
"noImplicitThis": true,
"incremental": true,
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "src",
"baseUrl": "./src",
"typeRoots": ["./node_modules/@types"]
},
"include": ["src/**/*"],
"references": [
{
"path": "../anastasis-core/"
},
{
"path": "../taler-util/"
}
]
}

View File

@ -23,7 +23,6 @@
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"fflate": "^0.7.4",
"hash-wasm": "^4.9.0",
"tslib": "^2.5.3"
},
"ava": {

View File

@ -1,7 +0,0 @@
import { reducerCliMain } from "./cli.js";
async function r() {
reducerCliMain();
}
r();

View File

@ -1,64 +0,0 @@
import { clk } from "@gnu-taler/taler-util/clk";
import {
getBackupStartState,
getRecoveryStartState,
reduceAction,
} from "./index.js";
import fs from "fs";
export const reducerCli = clk
.program("reducer", {
help: "Command line interface for Anastasis.",
})
.flag("initBackup", ["-b", "--backup"])
.flag("initRecovery", ["-r", "--restore"])
.maybeOption("argumentsJson", ["-a", "--arguments"], clk.STRING)
.maybeArgument("action", clk.STRING)
.maybeArgument("stateFile", clk.STRING);
async function read(stream: NodeJS.ReadStream): Promise<string> {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
reducerCli.action(async (x) => {
if (x.reducer.initBackup) {
console.log(JSON.stringify(await getBackupStartState()));
return;
} else if (x.reducer.initRecovery) {
console.log(JSON.stringify(await getRecoveryStartState()));
return;
}
const action = x.reducer.action;
if (!action) {
console.log("action required");
return;
}
let lastState: any;
if (x.reducer.stateFile) {
const s = fs.readFileSync(x.reducer.stateFile, { encoding: "utf-8" });
lastState = JSON.parse(s);
} else {
const s = await read(process.stdin);
lastState = JSON.parse(s);
}
let args: any;
if (x.reducer.argumentsJson) {
args = JSON.parse(x.reducer.argumentsJson);
} else {
args = {};
}
const nextState = await reduceAction(lastState, action, args);
console.log(JSON.stringify(nextState));
});
export function reducerCliMain() {
reducerCli.run();
}

View File

@ -26,8 +26,8 @@ import {
secretbox_open,
hash,
bytesToString,
hashArgon2id,
} from "@gnu-taler/taler-util";
import { argon2id } from "hash-wasm";
export type Flavor<T, FlavorT extends string> = T & {
_flavor?: `anastasis.${FlavorT}`;
@ -71,15 +71,13 @@ export async function userIdentifierDerive(
): Promise<UserIdentifier> {
const canonIdData = canonicalJson(idData);
const hashInput = stringToBytes(canonIdData);
const result = await argon2id({
hashLength: 64,
iterations: 3,
memorySize: 1024 /* kibibytes */,
parallelism: 1,
password: hashInput,
salt: decodeCrock(serverSalt),
outputType: "binary",
});
const result = await hashArgon2id(
hashInput, // password
decodeCrock(serverSalt), // salt
3, // iterations
1024, // memoryLimit (kibibytes)
64, // hashLength
);
return encodeCrock(result);
}
@ -153,7 +151,11 @@ export async function decryptPolicyMetadata(
userId: UserIdentifier,
metadataEnc: OpaqueData,
): Promise<PolicyMetadata> {
// @ts-ignore
console.log("metadataEnc", metadataEnc);
const plain = await anastasisDecrypt(asOpaque(userId), metadataEnc, "rmd");
// @ts-ignore
console.log("plain:", plain);
const metadataBytes = decodeCrock(plain);
const policyHash = encodeCrock(metadataBytes.slice(0, 64));
const secretName = bytesToString(metadataBytes.slice(64));
@ -343,15 +345,13 @@ export async function secureAnswerHash(
truthUuid: TruthUuid,
questionSalt: TruthSalt,
): Promise<SecureAnswerHash> {
const powResult = await argon2id({
hashLength: 64,
iterations: 3,
memorySize: 1024 /* kibibytes */,
parallelism: 1,
password: stringToBytes(answer),
salt: decodeCrock(questionSalt),
outputType: "binary",
});
const powResult = await hashArgon2id(
stringToBytes(answer), // password
decodeCrock(questionSalt), // salt
3, // iterations
1024, // memorySize (kibibytes)
64, // hashLength
);
const kdfResult = kdfKw({
outputLength: 64,
salt: decodeCrock(truthUuid),

View File

@ -1,2 +0,0 @@
export * from "./index.js";
export { reducerCliMain } from "./cli.js";

View File

@ -2,11 +2,11 @@
"compileOnSave": true,
"compilerOptions": {
"composite": true,
"target": "ES2018",
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node16",
"sourceMap": true,
"lib": ["es6"],
"lib": ["ES2020"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,

View File

@ -136,6 +136,7 @@ export interface DiscoveryUiState {
export interface AnastasisReducerApi {
currentReducerState: ReducerState | undefined;
// FIXME: Explain better!
currentError: any;
discoveryState: DiscoveryUiState;
dismissError: () => void;

View File

@ -46,8 +46,9 @@ export function ContinentSelectionScreen(): VNode {
// const cc = reducer.currentReducerState.selected_country || "";
const theCountry = countryList.find((c) => c.code === countryCode);
const selectCountryAction = async () => {
//selection should be when the select box changes it value
// selection should be when the select box changes it value
if (!theCountry) return;
// FIXME: Why is there no await?
reducer.transition("select_country", {
country_code: countryCode,
});
@ -56,6 +57,7 @@ export function ContinentSelectionScreen(): VNode {
// const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting ||
// reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting;
// FIXME: i18n
const errors = !theCountry ? "Select a country" : undefined;
const handleBack = async () => {

View File

@ -228,6 +228,8 @@ function AnastasisClientImpl(): VNode {
return <StartScreen />;
}
// FIXME: Use switch statements here!
if (
(state.reducer_type === "backup" &&
state.backup_state === BackupStates.ContinentSelecting) ||

View File

@ -21,9 +21,11 @@ export function Loading(): VNode {
<div
class="columns is-centered is-vcentered"
style={{
height: "calc(100% - 3rem)",
position: "absolute",
width: "100%",
height: "200px",
display: "flex",
margin: "auto",
justifyContent: "center",
}}
>
<Spinner />
@ -33,7 +35,7 @@ export function Loading(): VNode {
export function Spinner(): VNode {
return (
<div class="lds-ring">
<div class="lds-ring" style={{margin:"auto"}}>
<div />
<div />
<div />

View File

@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: 2022-12-26 23:30+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"PO-Revision-Date: 2023-08-15 07:28+0000\n"
"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
"taler-bank-spa/it/>\n"
"Language: it\n"
@ -199,9 +199,9 @@ msgid "Amount to withdraw:"
msgstr "Somma da ritirare"
#: src/pages/home/WalletWithdrawForm.tsx:84
#, fuzzy, c-format
#, c-format
msgid "Withdraw"
msgstr "Conferma il ritiro"
msgstr "Prelevare"
#: src/pages/home/WalletWithdrawForm.tsx:128
#, fuzzy, c-format
@ -231,12 +231,12 @@ msgstr "Trasferisci fondi a un altro conto di questa banca:"
#: src/pages/home/Transactions.tsx:69
#, c-format
msgid "Date"
msgstr ""
msgstr "Data"
#: src/pages/home/Transactions.tsx:70
#, c-format
msgid "Amount"
msgstr "Somma"
msgstr "Importo"
#: src/pages/home/Transactions.tsx:71
#, c-format
@ -246,7 +246,7 @@ msgstr "Controparte"
#: src/pages/home/Transactions.tsx:72
#, c-format
msgid "Subject"
msgstr "Causale"
msgstr "Soggetto"
#: src/pages/home/QrCodeSection.tsx:41
#, fuzzy, c-format

View File

@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util";
import { Amounts, HttpStatusCode, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpResponsePaginated,
@ -27,6 +27,7 @@ import { useAccountDetails } from "../hooks/access.js";
import { LoginForm } from "./LoginForm.js";
import { PaymentOptions } from "./PaymentOptions.js";
import { notifyError } from "../hooks/notification.js";
import { useEffect, useState } from "preact/hooks";
interface Props {
account: string;
@ -34,6 +35,60 @@ interface Props {
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
}
export const CopyIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
/>
<path
fill-rule="evenodd"
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
/>
</svg>
);
export const CopiedIcon = (): VNode => (
<svg height="16" viewBox="0 0 16 16" width="16">
<path
fill-rule="evenodd"
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
/>
</svg>
);
function CopyButton({ getContent }: { getContent: () => string }): VNode {
const [copied, setCopied] = useState(false);
function copyText(): void {
navigator.clipboard.writeText(getContent() || "");
setCopied(true);
}
useEffect(() => {
if (copied) {
setTimeout(() => {
setCopied(false);
}, 1000);
}
}, [copied]);
if (!copied) {
return (
<button onClick={copyText} style={{width:32, height:32, fontSize: "initial"}}>
<CopyIcon />
</button>
);
}
return (
<div content="Copied" style={{display:"inline-block"}}>
<button disabled style={{width:32, height:32 , fontSize: "initial"}}>
<CopiedIcon />
</button>
</div>
);
}
/**
* Query account information and show QR code if there is pending withdrawal
*/
@ -66,7 +121,6 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
<div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
);
}
const accountNumber = payto.iban;
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
@ -76,8 +130,7 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
<div>
<h1 class="nav welcome-text">
<i18n.Translate>
Welcome, {accountNumber ? `${account} (${accountNumber})` : account}
!
Welcome, {account} (<a href={stringifyPaytoUri(payto)}>{payto.iban}</a>)! <CopyButton getContent={() => stringifyPaytoUri(payto)} />
</i18n.Translate>
</h1>
</div>

View File

@ -84,11 +84,11 @@ export function HomePage({
export function WithdrawalOperationPage({
operationId,
onLoadNotOk,
onAbort,
onContinue,
}: {
operationId: string;
onLoadNotOk: () => void;
onAbort: () => void;
onContinue: () => void;
}): VNode {
//FIXME: libeufin sandbox should return show to create the integration api endpoint
//or return withdrawal uri from response
@ -99,12 +99,6 @@ export function WithdrawalOperationPage({
const parsedUri = parseWithdrawUri(uri);
const { i18n } = useTranslationContext();
const [settings, updateSettings] = useSettings();
function clearCurrentWithdrawal(): void {
updateSettings("currentWithdrawalOperationId", undefined);
onAbort();
}
if (!parsedUri) {
notifyError({
title: i18n.str`The Withdrawal URI is not valid: "${uri}"`,
@ -115,10 +109,7 @@ export function WithdrawalOperationPage({
return (
<WithdrawalQRCode
withdrawUri={parsedUri}
onConfirmed={() => {
notifyInfo(i18n.str`Withdrawal confirmed!`);
}}
onAborted={clearCurrentWithdrawal}
onContinue={onContinue}
onLoadNotOk={onLoadNotOk}
/>
);

View File

@ -40,7 +40,7 @@ export function Routing(): VNode {
component={({ wopid }: { wopid: string }) => (
<WithdrawalOperationPage
operationId={wopid}
onAbort={() => {
onContinue={() => {
route("/account");
}}
onLoadNotOk={() => {

View File

@ -33,7 +33,6 @@ import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("WithdrawalConfirmationQuestion");
interface Props {
onConfirmed: () => void;
onAborted: () => void;
withdrawUri: WithdrawUriResult;
}
@ -42,7 +41,6 @@ interface Props {
* Not providing a back button, only abort.
*/
export function WithdrawalConfirmationQuestion({
onConfirmed,
onAborted,
withdrawUri,
}: Props): VNode {
@ -119,7 +117,6 @@ export function WithdrawalConfirmationQuestion({
await confirmWithdrawal(
withdrawUri.withdrawalOperationId,
);
onConfirmed();
} catch (error) {
if (error instanceof RequestError) {
notifyError(

View File

@ -24,6 +24,7 @@ import { Fragment, VNode, h } from "preact";
import { Loading } from "../components/Loading.js";
import { useWithdrawalDetails } from "../hooks/access.js";
import { notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js";
import { handleNotOkResult } from "./HomePage.js";
import { QrCodeSection } from "./QrCodeSection.js";
import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
@ -32,8 +33,7 @@ const logger = new Logger("WithdrawalQRCode");
interface Props {
withdrawUri: WithdrawUriResult;
onAborted: () => void;
onConfirmed: () => void;
onContinue: () => void;
onLoadNotOk: () => void;
}
/**
@ -43,10 +43,14 @@ interface Props {
*/
export function WithdrawalQRCode({
withdrawUri,
onConfirmed,
onAborted,
onContinue,
onLoadNotOk,
}: Props): VNode {
const [settings, updateSettings] = useSettings();
function clearCurrentWithdrawal(): void {
updateSettings("currentWithdrawalOperationId", undefined);
onContinue();
}
const { i18n } = useTranslationContext();
const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
if (!result.ok) {
@ -64,13 +68,64 @@ export function WithdrawalQRCode({
}
const { data } = result;
logger.trace("withdrawal status", data);
if (data.aborted || data.confirmation_done) {
// signal that this withdrawal is aborted
// will redirect to account info
notifyInfo(i18n.str`Operation completed`);
onAborted();
return <Loading />;
if (data.aborted) {
return <section id="main" class="content">
<h1 class="nav">{i18n.str`Operation aborted`}</h1>
<section>
<p>
<i18n.Translate>
The wire transfer to the GNU Taler Exchange bank's account was aborted, your balance
was not affected.
</i18n.Translate>
</p>
<p>
<i18n.Translate>
You can close this page now or continue to the account page.
</i18n.Translate>
</p>
<a class="pure-button pure-button-primary"
style={{float:"right"}}
onClick={async (e) => {
e.preventDefault();
clearCurrentWithdrawal()
onContinue()
}}>
{i18n.str`Continue`}
</a>
</section>
</section>
}
if (data.confirmation_done) {
return <section id="main" class="content">
<h1 class="nav">{i18n.str`Operation completed`}</h1>
<section id="assets" style={{maxWidth: 400, marginLeft: "auto", marginRight:"auto"}}>
<p>
<i18n.Translate>
The wire transfer to the GNU Taler Exchange bank's account is completed, now the
exchange will send the requested amount into your GNU Taler wallet.
</i18n.Translate>
</p>
<p>
<i18n.Translate>
You can close this page now or continue to the account page.
</i18n.Translate>
</p>
<div style={{textAlign:"center"}}>
<a class="pure-button pure-button-primary"
onClick={async (e) => {
e.preventDefault();
clearCurrentWithdrawal()
onContinue()
}}>
{i18n.str`Continue`}
</a>
</div>
</section>
</section>
}
if (!data.selection_done) {
@ -79,25 +134,21 @@ export function WithdrawalQRCode({
withdrawUri={withdrawUri}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
onAborted();
}}
clearCurrentWithdrawal()
onContinue()
}}
/>
);
}
// Wallet POSTed the withdrawal details! Ask the
// user to authorize the operation (here CAPTCHA).
return (
<WithdrawalConfirmationQuestion
withdrawUri={withdrawUri}
onConfirmed={() => {
notifyInfo(i18n.str`Operation confirmed`);
onConfirmed();
}}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
onAborted();
}}
clearCurrentWithdrawal()
onContinue()
}}
/>
);
}
}

View File

@ -314,4 +314,40 @@ h1.nav {
[name=wire-transfer-form] > input {
margin-bottom: 1em;
}
}
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid black;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: black transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -134,7 +134,7 @@ export function buildRequestErrorMessage(
specialCases.onClientError && specialCases.onClientError(cause.status);
result = {
title: title ? title : i18n.str`The server didn't accept the request`,
description: cause.payload.error.description,
description: cause?.payload?.error?.description,
debug: JSON.stringify(cause),
};
break;
@ -146,7 +146,7 @@ export function buildRequestErrorMessage(
title: title
? title
: i18n.str`The server had problems processing the request`,
description: cause.payload.error.description,
description: cause?.payload?.error?.description,
debug: JSON.stringify(cause),
};
break;
@ -154,7 +154,7 @@ export function buildRequestErrorMessage(
case ErrorType.UNREADABLE: {
result = {
title: i18n.str`Unexpected error`,
description: `Response from ${cause.info?.url} is unreadable, status: ${cause.status}`,
description: `Response from ${cause?.info?.url} is unreadable, status: ${cause?.status}`,
debug: JSON.stringify(cause),
};
break;

View File

@ -26,7 +26,7 @@ import {
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { route } from "preact-router";
import { useMemo } from "preact/hooks";
import { useMemo, useState } from "preact/hooks";
import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js";
import { Loading } from "./components/exception/loading.js";
import {
@ -42,6 +42,7 @@ import { useBackendConfig } from "./hooks/backend.js";
import { strings } from "./i18n/strings.js";
import LoginPage from "./paths/login/index.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js";
export function Application(): VNode {
return (
@ -70,10 +71,19 @@ function ApplicationStatusRoutes(): VNode {
: { currency: "unknown", version: "unknown" };
const ctx = useMemo(() => ({ currency, version }), [currency, version]);
const [showSettings, setShowSettings] = useState(false)
if (showSettings) {
return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" />
<Settings />
</Fragment>
}
if (!triedToLog) {
return (
<Fragment>
<NotYetReadyAppMenu title="Welcome!" />
<NotYetReadyAppMenu title="Welcome!" onShowSettings={() => setShowSettings(true)} />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
</Fragment>
);
@ -87,7 +97,7 @@ function ApplicationStatusRoutes(): VNode {
) {
return (
<Fragment>
<NotYetReadyAppMenu title="Login" />
<NotYetReadyAppMenu title="Login" onShowSettings={() => setShowSettings(true)} />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
</Fragment>
);
@ -98,7 +108,7 @@ function ApplicationStatusRoutes(): VNode {
) {
return (
<Fragment>
<NotYetReadyAppMenu title="Error" />
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} />
<NotificationCard
notification={{
message: i18n.str`Server not found`,
@ -112,7 +122,7 @@ function ApplicationStatusRoutes(): VNode {
}
if (result.type === ErrorType.SERVER) {
<Fragment>
<NotYetReadyAppMenu title="Error" />
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} />
<NotificationCard
notification={{
message: i18n.str`Server response with an error code`,
@ -125,7 +135,7 @@ function ApplicationStatusRoutes(): VNode {
}
if (result.type === ErrorType.UNREADABLE) {
<Fragment>
<NotYetReadyAppMenu title="Error" />
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} />
<NotificationCard
notification={{
message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
@ -138,7 +148,7 @@ function ApplicationStatusRoutes(): VNode {
}
return (
<Fragment>
<NotYetReadyAppMenu title="Error" />
<NotYetReadyAppMenu title="Error" onShowSettings={() => setShowSettings(true)} />
<NotificationCard
notification={{
message: i18n.str`Unexpected Error`,

View File

@ -33,6 +33,7 @@ import { InstanceRoutes } from "./InstanceRoutes.js";
import LoginPage from "./paths/login/index.js";
import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js";
export function ApplicationReadyRoutes(): VNode {
const { i18n } = useTranslationContext();
@ -48,8 +49,15 @@ export function ApplicationReadyRoutes(): VNode {
clearAllTokens();
route("/");
};
const [showSettings, setShowSettings] = useState(false)
if (result.loading) return <NotYetReadyAppMenu title="Loading..." />;
if (showSettings) {
return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} />
<Settings/>
</Fragment>
}
if (result.loading) return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." />;
let admin = true;
let instanceNameByBackendURL;
@ -61,7 +69,7 @@ export function ApplicationReadyRoutes(): VNode {
) {
return (
<Fragment>
<NotYetReadyAppMenu title="Login" onLogout={clearTokenAndGoToRoot} />
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} />
<NotificationCard
notification={{
message: i18n.str`Access denied`,
@ -81,7 +89,7 @@ export function ApplicationReadyRoutes(): VNode {
// does not match our pattern
return (
<Fragment>
<NotYetReadyAppMenu title="Error" onLogout={clearTokenAndGoToRoot} />
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} />
<NotificationCard
notification={{
message: i18n.str`Couldn't access the server.`,

View File

@ -68,6 +68,7 @@ import LoginPage from "./paths/login/index.js";
import NotFoundPage from "./paths/notfound/index.js";
import { Notification } from "./utils/types.js";
import { MerchantBackend } from "./declaration.js";
import { Settings } from "./paths/settings/index.js";
export enum InstancePaths {
// details = '/',
@ -100,6 +101,8 @@ export enum InstancePaths {
webhooks_list = "/webhooks",
webhooks_update = "/webhooks/:tid/update",
webhooks_new = "/webhooks/new",
settings = "/settings",
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
@ -240,6 +243,9 @@ export function InstanceRoutes({
<Menu
instance={id}
admin={admin}
onShowSettings={() => {
route("/settings")
}}
path={path}
onLogout={clearTokenAndGoToRoot}
setInstanceName={setInstanceName}
@ -558,6 +564,7 @@ export function InstanceRoutes({
}}
/>
<Route path={InstancePaths.kyc} component={ListKYCPage} />
<Route path={InstancePaths.settings} component={Settings} />
{/**
* Example pages
*/}

View File

@ -20,7 +20,7 @@
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { ComponentChildren, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../../context/backend.js";
import { useInstanceContext } from "../../context/instance.js";
@ -40,7 +40,7 @@ function getTokenValuePart(t: string): string {
}
function normalizeToken(r: string): string {
return `secret-token:${encodeURIComponent(r)}`;
return `secret-token:${r}`;
}
function cleanUp(s: string): string {
@ -53,7 +53,7 @@ function cleanUp(s: string): string {
export function LoginModal({ onConfirm, withMessage }: Props): VNode {
const { url: backendUrl, token: baseToken } = useBackendContext();
const { admin, token: instanceToken } = useInstanceContext();
const { admin, token: instanceToken, id } = useInstanceContext();
const testLogin = useCredentialsChecker();
const currentToken = getTokenValuePart(
(!admin ? baseToken : instanceToken) ?? "",
@ -63,6 +63,78 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode {
const [url, setURL] = useState(cleanUp(backendUrl));
const { i18n } = useTranslationContext();
if (admin && id !== "default") {
//admin trying to access another instance
return (<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Login required`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<p>
<i18n.Translate>Need the access token for the instance.</i18n.Translate>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Access Token</i18n.Translate>
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="password"
placeholder={"set new access token"}
name="token"
onKeyPress={(e) =>
e.keyCode === 13
? onConfirm(url, normalizeToken(token))
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(`${url}/instances/${id}`, secretToken);
if (valid) {
onConfirm(url, secretToken);
} else {
onConfirm(url);
}
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>)
}
return (
<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
@ -137,8 +209,7 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode {
borderTop: 0,
}}
>
<button
class="button is-info"
<AsyncButton
onClick={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(url, secretToken);
@ -150,10 +221,24 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode {
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</button>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}
function AsyncButton({ onClick, children }: { onClick: () => Promise<void>, children: ComponentChildren }): VNode {
const [running, setRunning] = useState(false)
return <button class="button is-info" disabled={running} onClick={() => {
setRunning(true)
onClick().then(() => {
setRunning(false)
}).catch(() => {
setRunning(false)
})
}}>
{children}
</button>
}

View File

@ -44,7 +44,7 @@ export function InputSelector<T>({
fromStr = defaultFromString,
toStr = defaultToString,
}: Props<keyof T>): VNode {
const { error, value, onChange } = useField<T>(name);
const { error, value, onChange, required } = useField<T>(name);
return (
<div class="field is-horizontal">
<div class="field-label is-normal">
@ -58,8 +58,8 @@ export function InputSelector<T>({
</label>
</div>
<div class="field-body is-flex-grow-3">
<div class="field">
<p class={expand ? "control is-expanded select" : "control select"}>
<div class="field has-icons-right">
<p class={expand ? "control is-expanded select" : "control select "}>
<select
class={error ? "select is-danger" : "select"}
name={String(name)}
@ -78,8 +78,14 @@ export function InputSelector<T>({
);
})}
</select>
{help}
</p>
{required && (
<span class="icon has-text-danger is-right" style={{height: "2.5em"}}>
<i class="mdi mdi-alert" />
</span>
)}
{error && <p class="help is-danger">{error}</p>}
</div>
</div>

View File

@ -0,0 +1,91 @@
/*
This file is part of GNU Taler
(C) 2021-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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { h, VNode } from "preact";
import { InputProps, useField } from "./useField.js";
interface Props<T> extends InputProps<T> {
name: T;
readonly?: boolean;
expand?: boolean;
threeState?: boolean;
toBoolean?: (v?: any) => boolean | undefined;
fromBoolean?: (s: boolean | undefined) => any;
}
const defaultToBoolean = (f?: any): boolean | undefined => f || "";
const defaultFromBoolean = (v: boolean | undefined): any => v as any;
export function InputToggle<T>({
name,
readonly,
placeholder,
tooltip,
label,
help,
threeState,
expand,
fromBoolean = defaultFromBoolean,
toBoolean = defaultToBoolean,
}: Props<keyof T>): VNode {
const { error, value, onChange } = useField<T>(name);
const onCheckboxClick = (): void => {
const c = toBoolean(value);
if (c === false && threeState) return onChange(undefined as any);
return onChange(fromBoolean(!c));
};
return (
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" style={{ width: 200 }}>
{label}
{tooltip && (
<span class="icon has-tooltip-right" data-tooltip={tooltip}>
<i class="mdi mdi-information" />
</span>
)}
</label>
</div>
<div class="field-body is-flex-grow-1">
<div class="field">
<p class={expand ? "control is-expanded" : "control"}>
<label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
<input
type="checkbox"
class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"}
checked={toBoolean(value)}
placeholder={placeholder}
readonly={readonly}
name={String(name)}
disabled={readonly}
onChange={onCheckboxClick}
/>
<div class="toggle-switch"></div>
</label>
{help}
</p>
{error && <p class="help is-danger">{error}</p>}
</div>
</div>
</div>
);
}

View File

@ -20,7 +20,6 @@
*/
import { h, VNode } from "preact";
import { LangSelector } from "./LangSelector.js";
import logo from "../../assets/logo-2021.svg";
interface Props {
@ -65,7 +64,6 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode {
</a>
<div class="navbar-end">
<div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}>
<LangSelector />
</div>
</div>
</div>

View File

@ -31,6 +31,7 @@ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
interface Props {
onLogout: () => void;
onShowSettings: () => void;
mobile?: boolean;
instance: string;
admin?: boolean;
@ -40,6 +41,7 @@ interface Props {
export function Sidebar({
mobile,
instance,
onShowSettings,
onLogout,
admin,
mimic,
@ -78,21 +80,8 @@ export function Sidebar({
<div class="menu is-menu-main">
{instance ? (
<Fragment>
<p class="menu-label">
<i18n.Translate>Instance</i18n.Translate>
</p>
<ul class="menu-list">
<li>
<a href={"/update"} class="has-icon">
<span class="icon">
<i class="mdi mdi-square-edit-outline" />
</span>
<span class="menu-item-label">
<i18n.Translate>Settings</i18n.Translate>
</span>
</a>
</li>
<li>
<li>
<a href={"/orders"} class="has-icon">
<span class="icon">
<i class="mdi mdi-cash-register" />
@ -132,6 +121,31 @@ export function Sidebar({
</span>
</a>
</li>
{needKYC && (
<li>
<a href={"/kyc"} class="has-icon">
<span class="icon">
<i class="mdi mdi-account-check" />
</span>
<span class="menu-item-label">KYC Status</span>
</a>
</li>
)}
</ul>
<p class="menu-label">
<i18n.Translate>Configuration</i18n.Translate>
</p>
<ul class="menu-list">
<li>
<a href={"/update"} class="has-icon">
<span class="icon">
<i class="mdi mdi-square-edit-outline" />
</span>
<span class="menu-item-label">
<i18n.Translate>Account</i18n.Translate>
</span>
</a>
</li>
<li>
<a href={"/reserves"} class="has-icon">
<span class="icon">
@ -150,16 +164,6 @@ export function Sidebar({
</span>
</a>
</li>
{needKYC && (
<li>
<a href={"/kyc"} class="has-icon">
<span class="icon">
<i class="mdi mdi-account-check" />
</span>
<span class="menu-item-label">KYC Status</span>
</a>
</li>
)}
</ul>
</Fragment>
) : undefined}
@ -167,6 +171,18 @@ export function Sidebar({
<i18n.Translate>Connection</i18n.Translate>
</p>
<ul class="menu-list">
<li>
<a class="has-icon is-state-info is-hoverable"
onClick={(): void => onShowSettings()}
>
<span class="icon">
<i class="mdi mdi-newspaper" />
</span>
<span class="menu-item-label">
<i18n.Translate>Settings</i18n.Translate>
</span>
</a>
</li>
<li>
<div>
<span style={{ width: "3rem" }} class="icon">

View File

@ -75,6 +75,7 @@ interface MenuProps {
instance: string;
admin?: boolean;
onLogout?: () => void;
onShowSettings: () => void;
setInstanceName: (s: string) => void;
}
@ -93,6 +94,7 @@ function WithTitle({
export function Menu({
onLogout,
onShowSettings,
title,
instance,
path,
@ -121,6 +123,7 @@ export function Menu({
{onLogout && (
<Sidebar
onShowSettings={onShowSettings}
onLogout={onLogout}
admin={admin}
mimic={mimic}
@ -130,7 +133,12 @@ export function Menu({
)}
{mimic && (
<nav class="level">
<nav class="level" style={{
zIndex: 100,
position:"fixed",
width:"50%",
marginLeft: "20%"
}}>
<div class="level-item has-text-centered has-background-warning">
<p class="is-size-5">
You are viewing the instance <b>&quot;{instance}&quot;</b>.{" "}
@ -154,6 +162,7 @@ export function Menu({
interface NotYetReadyAppMenuProps {
title: string;
onLogout?: () => void;
onShowSettings: () => void;
}
interface NotifProps {
@ -194,6 +203,7 @@ export function NotificationCard({
export function NotYetReadyAppMenu({
onLogout,
onShowSettings,
title,
}: NotYetReadyAppMenuProps): VNode {
const [mobileOpen, setMobileOpen] = useState(false);
@ -212,7 +222,7 @@ export function NotYetReadyAppMenu({
title={title}
/>
{onLogout && (
<Sidebar onLogout={onLogout} instance="" mobile={mobileOpen} />
<Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} />
)}
</div>
);

View File

@ -1331,12 +1331,13 @@ export namespace MerchantBackend {
}
namespace Webhooks {
type MerchantWebhookType = "pay" | "refund";
interface WebhookAddDetails {
// Webhook ID to use.
webhook_id: string;
// The event of the webhook: why the webhook is used.
event_type: string;
event_type: MerchantWebhookType;
// URL of the webhook where the customer will be redirected.
url: string;

View File

@ -239,16 +239,16 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
searchDate?: Date,
delta?: number,
): Promise<HttpResponseOk<T>> {
const date_ms =
const date_s =
delta && delta < 0 && searchDate
? searchDate.getTime() + 1
: searchDate?.getTime();
? (searchDate.getTime() / 1000) + 1
: searchDate !== undefined ? (searchDate.getTime() / 1000) : undefined;
const params: any = {};
if (paid !== undefined) params.paid = paid;
if (delta !== undefined) params.delta = delta;
if (refunded !== undefined) params.refunded = refunded;
if (wired !== undefined) params.wired = wired;
if (date_ms !== undefined) params.date_ms = date_ms;
if (date_s !== undefined) params.date_s = date_s;
return requestHandler<T>(baseUrl, endpoint, { params, token });
},
[baseUrl, token],

View File

@ -21,6 +21,7 @@
import { StateUpdater, useCallback, useState } from "preact/hooks";
import { ValueOrFunction } from "../utils/types.js";
import { useMemoryStorage } from "@gnu-taler/web-util/browser";
const calculateRootPath = () => {
const rootPath =
@ -52,14 +53,17 @@ export function useBackendURL(
export function useBackendDefaultToken(
initialValue?: string,
): [string | undefined, StateUpdater<string | undefined>] {
return useLocalStorage("backend-token", initialValue);
): [string | undefined, ((d: string | undefined) => void)] {
// uncomment for testing
initialValue = "secret-token:secret" as string | undefined
const { update, value } = useMemoryStorage(`backend-token`, initialValue)
return [value, update];
}
export function useBackendInstanceToken(
id: string,
): [string | undefined, StateUpdater<string | undefined>] {
const [token, setToken] = useLocalStorage(`backend-token-${id}`);
): [string | undefined, ((d: string | undefined) => void)] {
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`)
const [defaultToken, defaultSetToken] = useBackendDefaultToken();
// instance named 'default' use the default token
@ -67,15 +71,16 @@ export function useBackendInstanceToken(
return [defaultToken, defaultSetToken];
}
function updateToken(
value:
| (string | undefined)
| ((s: string | undefined) => string | undefined),
value: (string | undefined)
): void {
setToken((p) => {
const toStore = value instanceof Function ? value(p) : value;
return toStore;
});
console.log("seeting token", value)
if (value === undefined) {
reset()
} else {
setToken(value)
}
}
console.log("token", token)
return [token, updateToken];
}

View File

@ -0,0 +1,59 @@
/*
This file is part of GNU Taler
(C) 2022 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 { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
import {
Codec,
buildCodecForObject,
codecForBoolean,
} from "@gnu-taler/taler-util";
function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
if (str === undefined) return undefined;
try {
return JSON.parse(str);
} catch {
return undefined;
}
}
export interface Settings {
advanceOrderMode: boolean
}
const defaultSettings: Settings = {
advanceOrderMode: false,
}
export const codecForSettings = (): Codec<Settings> =>
buildCodecForObject<Settings>()
.property("advanceOrderMode", codecForBoolean())
.build("Settings");
const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings());
export function useSettings(): [
Readonly<Settings>,
<T extends keyof Settings>(key: T, value: Settings[T]) => void,
] {
const { value, update } = useLocalStorage(SETTINGS_KEY);
const parsed: Settings = value ?? defaultSettings;
function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
update({ ...parsed, [k]: v });
}
return [parsed, updateField];
}

View File

@ -12,20 +12,21 @@
# 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/>
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"PO-Revision-Date: 2023-08-15 07:28+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
"merchant-backoffice/de/>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.13.1\n"
#: src/components/modal/index.tsx:71
#, c-format
@ -1252,7 +1253,7 @@ msgstr ""
#: src/paths/instance/orders/list/ListPage.tsx:145
#, c-format
msgid "Refunded"
msgstr ""
msgstr "Rückerstattet"
#: src/paths/instance/orders/list/ListPage.tsx:152
#, c-format

View File

@ -17,8 +17,8 @@ msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: 2023-04-24 06:43+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"PO-Revision-Date: 2023-08-13 10:14+0000\n"
"Last-Translator: Javier Sepulveda <javier.sepulveda@uv.es>\n"
"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/"
"merchant-backoffice/es/>\n"
"Language: es\n"
@ -1273,7 +1273,7 @@ msgstr "No se pudo create el reembolso"
#: src/paths/instance/orders/list/ListPage.tsx:145
#, c-format
msgid "Refunded"
msgstr "Reembolzado"
msgstr "Reembolsado"
#: src/paths/instance/orders/list/ListPage.tsx:152
#, c-format

View File

@ -12,20 +12,21 @@
# 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/>
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"PO-Revision-Date: 2023-08-15 07:28+0000\n"
"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
"merchant-backoffice/it/>\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.13.1\n"
#: src/components/modal/index.tsx:71
#, c-format
@ -439,7 +440,7 @@ msgstr ""
#: src/components/form/InputTaxes.tsx:119
#, c-format
msgid "Amount"
msgstr ""
msgstr "Importo"
#: src/components/form/InputTaxes.tsx:120
#, c-format
@ -887,7 +888,7 @@ msgstr ""
#: src/paths/instance/orders/list/Table.tsx:154
#, c-format
msgid "Date"
msgstr ""
msgstr "Data"
#: src/paths/instance/orders/list/Table.tsx:200
#, c-format
@ -1252,7 +1253,7 @@ msgstr ""
#: src/paths/instance/orders/list/ListPage.tsx:145
#, c-format
msgid "Refunded"
msgstr ""
msgstr "Rimborsato"
#: src/paths/instance/orders/list/ListPage.tsx:152
#, c-format
@ -1633,7 +1634,7 @@ msgstr ""
#: src/paths/instance/reserves/details/DetailPage.tsx:119
#, c-format
msgid "Subject"
msgstr ""
msgstr "Soggetto"
#: src/paths/instance/reserves/details/DetailPage.tsx:130
#, c-format

View File

@ -43,6 +43,7 @@ import { Duration, MerchantBackend, WithId } from "../../../../declaration.js";
import { OrderCreateSchema as schema } from "../../../../schemas/index.js";
import { rate } from "../../../../utils/amount.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
import { useSettings } from "../../../../hooks/useSettings.js";
interface Props {
onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void;
@ -62,8 +63,8 @@ function with_defaults(config: InstanceConfig): Partial<Entity> {
!config.default_pay_delay || config.default_pay_delay.d_us === "forever"
? undefined
: add(new Date(), {
seconds: config.default_pay_delay.d_us / (1000 * 1000),
});
seconds: config.default_pay_delay.d_us / (1000 * 1000),
});
return {
inventoryProducts: {},
@ -138,7 +139,7 @@ export function CreatePage({
const [value, valueHandler] = useState(with_defaults(instanceConfig));
const config = useConfigContext();
const zero = Amounts.zeroOfCurrency(config.currency);
const [settings] = useSettings()
const inventoryList = Object.values(value.inventoryProducts || {});
const productList = Object.values(value.products || {});
@ -154,10 +155,10 @@ export function CreatePage({
order_price: !value.pricing?.order_price
? i18n.str`required`
: !parsedPrice
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0`
: undefined,
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0`
: undefined,
}),
extra:
value.extra && !stringIsValidJSON(value.extra)
@ -167,47 +168,47 @@ export function CreatePage({
refund_deadline: !value.payments?.refund_deadline
? undefined
: !isFuture(value.payments.refund_deadline)
? i18n.str`should be in the future`
: value.payments.pay_deadline &&
isBefore(value.payments.refund_deadline, value.payments.pay_deadline)
? i18n.str`refund deadline cannot be before pay deadline`
: value.payments.wire_transfer_deadline &&
isBefore(
value.payments.wire_transfer_deadline,
value.payments.refund_deadline,
)
? i18n.str`wire transfer deadline cannot be before refund deadline`
: undefined,
? i18n.str`should be in the future`
: value.payments.pay_deadline &&
isBefore(value.payments.refund_deadline, value.payments.pay_deadline)
? i18n.str`refund deadline cannot be before pay deadline`
: value.payments.wire_transfer_deadline &&
isBefore(
value.payments.wire_transfer_deadline,
value.payments.refund_deadline,
)
? i18n.str`wire transfer deadline cannot be before refund deadline`
: undefined,
pay_deadline: !value.payments?.pay_deadline
? undefined
: !isFuture(value.payments.pay_deadline)
? i18n.str`should be in the future`
: value.payments.wire_transfer_deadline &&
isBefore(
value.payments.wire_transfer_deadline,
value.payments.pay_deadline,
)
? i18n.str`wire transfer deadline cannot be before pay deadline`
: undefined,
? i18n.str`should be in the future`
: value.payments.wire_transfer_deadline &&
isBefore(
value.payments.wire_transfer_deadline,
value.payments.pay_deadline,
)
? i18n.str`wire transfer deadline cannot be before pay deadline`
: undefined,
auto_refund_deadline: !value.payments?.auto_refund_deadline
? undefined
: !isFuture(value.payments.auto_refund_deadline)
? i18n.str`should be in the future`
: !value.payments?.refund_deadline
? i18n.str`should have a refund deadline`
: !isAfter(
value.payments.refund_deadline,
value.payments.auto_refund_deadline,
)
? i18n.str`auto refund cannot be after refund deadline`
: undefined,
? i18n.str`should be in the future`
: !value.payments?.refund_deadline
? i18n.str`should have a refund deadline`
: !isAfter(
value.payments.refund_deadline,
value.payments.auto_refund_deadline,
)
? i18n.str`auto refund cannot be after refund deadline`
: undefined,
}),
shipping: undefinedIfEmpty({
delivery_date: !value.shipping?.delivery_date
? undefined
: !isFuture(value.shipping.delivery_date)
? i18n.str`should be in the future`
: undefined,
? i18n.str`should be in the future`
: undefined,
}),
};
const hasErrors = Object.keys(errors).some(
@ -227,27 +228,27 @@ export function CreatePage({
extra: value.extra,
pay_deadline: value.payments.pay_deadline
? {
t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000),
}
t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000),
}
: undefined,
wire_transfer_deadline: value.payments.wire_transfer_deadline
? {
t_s: Math.floor(
value.payments.wire_transfer_deadline.getTime() / 1000,
),
}
t_s: Math.floor(
value.payments.wire_transfer_deadline.getTime() / 1000,
),
}
: undefined,
refund_deadline: value.payments.refund_deadline
? {
t_s: Math.floor(value.payments.refund_deadline.getTime() / 1000),
}
t_s: Math.floor(value.payments.refund_deadline.getTime() / 1000),
}
: undefined,
auto_refund: value.payments.auto_refund_deadline
? {
d_us: Math.floor(
value.payments.auto_refund_deadline.getTime() * 1000,
),
}
d_us: Math.floor(
value.payments.auto_refund_deadline.getTime() * 1000,
),
}
: undefined,
wire_fee_amortization: value.payments.wire_fee_amortization as number,
max_fee: value.payments.max_fee as string,
@ -374,13 +375,15 @@ export function CreatePage({
inventory={instanceInventory}
/>
<NonInventoryProductFrom
productToEdit={editingProduct}
onAddProduct={(p) => {
setEditingProduct(undefined);
return addNewProduct(p);
}}
/>
{settings.advanceOrderMode &&
<NonInventoryProductFrom
productToEdit={editingProduct}
onAddProduct={(p) => {
setEditingProduct(undefined);
return addNewProduct(p);
}}
/>
}
{allProducts.length > 0 && (
<ProductList
@ -423,8 +426,8 @@ export function CreatePage({
discountOrRise > 0 &&
(discountOrRise < 1
? `discount of %${Math.round(
(1 - discountOrRise) * 100,
)}`
(1 - discountOrRise) * 100,
)}`
: `rise of %${Math.round((discountOrRise - 1) * 100)}`)
}
tooltip={i18n.str`Amount to be paid by the customer`}
@ -445,102 +448,108 @@ export function CreatePage({
tooltip={i18n.str`Title of the order to be shown to the customer`}
/>
<InputGroup
name="shipping"
label={i18n.str`Shipping and Fulfillment`}
initialActive
>
<InputDate
name="shipping.delivery_date"
label={i18n.str`Delivery date`}
tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`}
/>
{value.shipping?.delivery_date && (
<InputGroup
name="shipping.delivery_location"
label={i18n.str`Location`}
tooltip={i18n.str`address where the products will be delivered`}
>
<InputLocation name="shipping.delivery_location" />
</InputGroup>
)}
<Input
name="shipping.fullfilment_url"
label={i18n.str`Fulfillment URL`}
tooltip={i18n.str`URL to which the user will be redirected after successful payment.`}
/>
</InputGroup>
{settings.advanceOrderMode &&
<InputGroup
name="shipping"
label={i18n.str`Shipping and Fulfillment`}
initialActive
>
<InputDate
name="shipping.delivery_date"
label={i18n.str`Delivery date`}
tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`}
/>
{value.shipping?.delivery_date && (
<InputGroup
name="shipping.delivery_location"
label={i18n.str`Location`}
tooltip={i18n.str`address where the products will be delivered`}
>
<InputLocation name="shipping.delivery_location" />
</InputGroup>
)}
<Input
name="shipping.fullfilment_url"
label={i18n.str`Fulfillment URL`}
tooltip={i18n.str`URL to which the user will be redirected after successful payment.`}
/>
</InputGroup>
}
<InputGroup
name="payments"
label={i18n.str`Taler payment options`}
tooltip={i18n.str`Override default Taler payment settings for this order`}
>
<InputDate
name="payments.pay_deadline"
label={i18n.str`Payment deadline`}
tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`}
/>
<InputDate
name="payments.refund_deadline"
label={i18n.str`Refund deadline`}
tooltip={i18n.str`Time until which the order can be refunded by the merchant.`}
/>
<InputDate
name="payments.wire_transfer_deadline"
label={i18n.str`Wire transfer deadline`}
tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`}
/>
<InputDate
name="payments.auto_refund_deadline"
label={i18n.str`Auto-refund deadline`}
tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`}
/>
{settings.advanceOrderMode &&
<InputGroup
name="payments"
label={i18n.str`Taler payment options`}
tooltip={i18n.str`Override default Taler payment settings for this order`}
>
<InputDate
name="payments.pay_deadline"
label={i18n.str`Payment deadline`}
tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`}
/>
<InputDate
name="payments.refund_deadline"
label={i18n.str`Refund deadline`}
tooltip={i18n.str`Time until which the order can be refunded by the merchant.`}
/>
<InputDate
name="payments.wire_transfer_deadline"
label={i18n.str`Wire transfer deadline`}
tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`}
/>
<InputDate
name="payments.auto_refund_deadline"
label={i18n.str`Auto-refund deadline`}
tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`}
/>
<InputCurrency
name="payments.max_fee"
label={i18n.str`Maximum deposit fee`}
tooltip={i18n.str`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
/>
<InputCurrency
name="payments.max_wire_fee"
label={i18n.str`Maximum wire fee`}
tooltip={i18n.str`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`}
/>
<InputNumber
name="payments.wire_fee_amortization"
label={i18n.str`Wire fee amortization`}
tooltip={i18n.str`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`}
/>
<InputBoolean
name="payments.createToken"
label={i18n.str`Create token`}
tooltip={i18n.str`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`}
/>
<InputNumber
name="payments.minimum_age"
label={i18n.str`Minimum age required`}
tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`}
help={
minAgeByProducts > 0
? i18n.str`Min age defined by the producs is ${minAgeByProducts}`
: undefined
}
/>
</InputGroup>
<InputCurrency
name="payments.max_fee"
label={i18n.str`Maximum deposit fee`}
tooltip={i18n.str`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
/>
<InputCurrency
name="payments.max_wire_fee"
label={i18n.str`Maximum wire fee`}
tooltip={i18n.str`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`}
/>
<InputNumber
name="payments.wire_fee_amortization"
label={i18n.str`Wire fee amortization`}
tooltip={i18n.str`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`}
/>
<InputBoolean
name="payments.createToken"
label={i18n.str`Create token`}
tooltip={i18n.str`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`}
/>
<InputNumber
name="payments.minimum_age"
label={i18n.str`Minimum age required`}
tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`}
help={
minAgeByProducts > 0
? i18n.str`Min age defined by the producs is ${minAgeByProducts}`
: undefined
}
/>
</InputGroup>
}
<InputGroup
name="extra"
label={i18n.str`Additional information`}
tooltip={i18n.str`Custom information to be included in the contract for this order.`}
>
<Input
{settings.advanceOrderMode &&
<InputGroup
name="extra"
inputType="multiline"
label={`Value`}
tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`}
/>
</InputGroup>
label={i18n.str`Additional information`}
tooltip={i18n.str`Custom information to be included in the contract for this order.`}
>
<Input
name="extra"
inputType="multiline"
label={`Value`}
tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`}
/>
</InputGroup>
}
</FormProvider>
<div class="buttons is-right mt-5">

View File

@ -21,7 +21,7 @@
import { AmountJson, Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
import { format, formatDistance } from "date-fns";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { FormProvider } from "../../../../components/form/FormProvider.js";
@ -223,6 +223,7 @@ function ClaimedPage({
</div>
</div>
</div>
<div class="level">
<div class="level-left">
<div class="level-item">
@ -419,6 +420,11 @@ function PaidPage({
}
}
const now = new Date()
const nextEvent = events.find((e) => {
return e.when.getTime() > now.getTime()
})
const [value, valueHandler] = useState<Partial<Paid>>(order);
const { url } = useBackendContext();
const refundHost = url.replace(/.*:\/\//, ""); // remove protocol part
@ -504,22 +510,13 @@ function PaidPage({
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
// maxWidth: '100%',
}}
>
<p>
<a
href={order.contract_terms.fulfillment_url}
rel="nofollow"
target="new"
>
{order.contract_terms.fulfillment_url}
</a>
</p>
<p>
{format(
new Date(order.contract_terms.timestamp.t_s * 1000),
"yyyy/MM/dd HH:mm:ss",
<i18n.Translate>Next event in </i18n.Translate> {formatDistance(
nextEvent!.when,
new Date(),
// "yyyy/MM/dd HH:mm:ss",
)}
</p>
</div>
@ -668,9 +665,9 @@ function UnpaidPage({
{order.creation_time.t_s === "never"
? "never"
: format(
new Date(order.creation_time.t_s * 1000),
"yyyy-MM-dd HH:mm:ss",
)}
new Date(order.creation_time.t_s * 1000),
"yyyy-MM-dd HH:mm:ss",
)}
</p>
</div>
</div>

View File

@ -67,7 +67,7 @@ export function Timeline({ events: e }: Props) {
);
case "start":
return (
<div class="timeline-marker is-icon is-success">
<div class="timeline-marker is-icon">
<i class="mdi mdi-flag " />
</div>
);
@ -104,7 +104,7 @@ export function Timeline({ events: e }: Props) {
}
})()}
<div class="timeline-content">
<p class="heading">{format(e.when, "yyyy/MM/dd HH:mm:ss")}</p>
{e.description !== "now" && <p class="heading">{format(e.when, "yyyy/MM/dd HH:mm:ss")}</p>}
<p>{e.description}</p>
</div>
</div>
@ -117,12 +117,12 @@ export interface Event {
when: Date;
description: string;
type:
| "start"
| "refund"
| "refund-taken"
| "wired"
| "wired-range"
| "deadline"
| "delivery"
| "now";
| "start"
| "refund"
| "refund-taken"
| "wired"
| "wired-range"
| "deadline"
| "delivery"
| "now";
}

View File

@ -164,7 +164,7 @@ export function ListPage({
<div class="field has-addons">
{jumpToDate && (
<div class="control">
<a class="button" onClick={() => onSelectDate(undefined)}>
<a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}>
<span
class="icon"
data-tooltip={i18n.str`clear date filter`}
@ -191,7 +191,7 @@ export function ListPage({
<div class="control">
<span class="has-tooltip-left" data-tooltip={dateTooltip}>
<a
class="button"
class="button is-fullwidth"
onClick={() => {
setPickDate(true);
}}

View File

@ -85,34 +85,34 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
template_contract: !state.template_contract
? undefined
: undefinedIfEmpty({
amount: !state.template_contract?.amount
? undefined
: !parsedPrice
amount: !state.template_contract?.amount
? undefined
: !parsedPrice
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0`
: undefined,
minimum_age:
state.template_contract.minimum_age < 0
? i18n.str`should be greater that 0`
? i18n.str`must be greater than 0`
: undefined,
pay_duration: !state.template_contract.pay_duration
? i18n.str`can't be empty`
: state.template_contract.pay_duration.d_us === "forever"
minimum_age:
state.template_contract.minimum_age < 0
? i18n.str`should be greater that 0`
: undefined,
pay_duration: !state.template_contract.pay_duration
? i18n.str`can't be empty`
: state.template_contract.pay_duration.d_us === "forever"
? undefined
: state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second
? i18n.str`to short`
: undefined,
} as Partial<MerchantTemplateContractDetails>),
? i18n.str`to short`
: undefined,
} as Partial<MerchantTemplateContractDetails>),
pos_key: !state.pos_key
? !state.pos_algorithm
? undefined
: i18n.str`required`
: !isBase32RFC3548Charset(state.pos_key)
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
};
const hasErrors = Object.keys(errors).some(
@ -139,7 +139,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
>
<InputWithAddon<Entity>
name="template_id"
addonBefore={`${backend.url}/instances/templates/`}
help={`${backend.url}/instances/templates/${state.template_id ?? ""}`}
label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`}
/>

View File

@ -34,6 +34,7 @@ import { useBackendContext } from "../../../../context/backend.js";
import { useConfigContext } from "../../../../context/config.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js";
import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Template.UsingTemplateDetails;
@ -64,46 +65,47 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
const fixedAmount = !!template.template_contract.amount;
const fixedSummary = !!template.template_contract.summary;
const params = new URLSearchParams();
const templateParams: Record<string, string> = {}
if (!fixedAmount) {
if (state.amount) {
params.append("amount", state.amount);
templateParams.amount = state.amount
} else {
params.append("amount", config.currency);
templateParams.amount = config.currency
}
}
if (!fixedSummary) {
params.append("summary", state.summary ?? "");
templateParams.summary = state.summary ?? ""
}
const paramsStr = fixedAmount && fixedSummary ? "" : "?" + params.toString();
const merchantURL = new URL(backendUrl);
const merchantBaseUrl = new URL(backendUrl).href;
const talerProto =
merchantURL.protocol === "http:" ? "taler+http:" : "taler:";
const payTemplateUri = `${talerProto}//pay-template/${merchantURL.hostname}/${templateId}${paramsStr}`;
const payTemplateUri = stringifyPayTemplateUri({
merchantBaseUrl,
templateId,
templateParams
})
const issuer = encodeURIComponent(
`${new URL(backendUrl).hostname}/${instanceId}`,
`${new URL(backendUrl).host}/${instanceId}`,
);
const oauthUri = !template.pos_algorithm
? undefined
: template.pos_algorithm === 1
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
const keySlice = template.pos_key?.substring(0, 4);
const oauthUriWithoutSecret = !template.pos_algorithm
? undefined
: template.pos_algorithm === 1
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: template.pos_algorithm === 2
? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30`
: undefined;
return (
<div>
{oauthUri && (

View File

@ -33,6 +33,7 @@ import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
type Entity = MerchantBackend.Webhooks.WebhookAddDetails;
@ -50,7 +51,9 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
const errors: FormErrors<Entity> = {
webhook_id: !state.webhook_id ? i18n.str`required` : undefined,
event_type: !state.event_type ? i18n.str`required` : undefined,
event_type: !state.event_type ? i18n.str`required`
: state.event_type !== "pay" && state.event_type !== "refund" ? i18n.str`it should be "pay" or "refund"`
: undefined,
http_method: !state.http_method
? i18n.str`required`
: !validMethod.includes(state.http_method)
@ -84,16 +87,30 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
label={i18n.str`ID`}
tooltip={i18n.str`Webhook ID to use`}
/>
<Input<Entity>
<InputSelector
name="event_type"
label={i18n.str`Event`}
values={[
i18n.str`Choose one...`,
i18n.str`pay`,
i18n.str`refund`,
]}
tooltip={i18n.str`The event of the webhook: why the webhook is used`}
/>
<Input<Entity>
<InputSelector
name="http_method"
label={i18n.str`Method`}
values={[
i18n.str`Choose one...`,
i18n.str`GET`,
i18n.str`POST`,
i18n.str`PUT`,
i18n.str`PATCH`,
i18n.str`HEAD`,
]}
tooltip={i18n.str`Method used by the webhook`}
/>
<Input<Entity>
name="url"
label={i18n.str`URL`}

View File

@ -25,7 +25,6 @@ import { Link } from "preact-router";
export default function NotFoundPage(): VNode {
return (
<div>
<h1>Error 404</h1>
<p>That page doesn&apos;t exist.</p>
<Link href="/">
<h4>Back to Home</h4>

View File

@ -0,0 +1,77 @@
import { VNode, h } from "preact";
import { LangSelector } from "../../components/menu/LangSelector.js";
import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser";
import { InputToggle } from "../../components/form/InputToggle.js";
import { Settings, useSettings } from "../../hooks/useSettings.js";
import { FormErrors, FormProvider } from "../../components/form/FormProvider.js";
import { useState } from "preact/hooks";
function getBrowserLang(): string | undefined {
if (typeof window === "undefined") return undefined;
if (window.navigator.languages) return window.navigator.languages[0];
if (window.navigator.language) return window.navigator.language;
return undefined;
}
export function Settings(): VNode {
const { i18n } = useTranslationContext()
const borwserLang = getBrowserLang()
const { update } = useLang()
const [value, updateValue] = useSettings()
const errors: FormErrors<Settings> = {
}
function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void {
const next = s(value)
updateValue("advanceOrderMode", next.advanceOrderMode ?? false)
}
return <div>
<section class="section is-main-section">
<div class="columns">
<div class="column" />
<div class="column is-four-fifths">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" style={{ width: 200 }}>
<i18n.Translate>Language</i18n.Translate>
<span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}>
<i class="mdi mdi-information" />
</span>
</label>
</div>
<div class="field has-addons">
<LangSelector />
&nbsp;
{borwserLang !== undefined && <button
data-tooltip={i18n.str`generate random secret key`}
class="button is-info mr-3"
onClick={(e) => {
update(borwserLang.substring(0, 2))
}}
>
<i18n.Translate>Set default</i18n.Translate>
</button>}
</div>
</div>
<FormProvider<Settings>
name="settings"
errors={errors}
object={value}
valueHandler={valueHandler}
>
<InputToggle<Settings>
label={i18n.str`Advance order creation`}
tooltip={i18n.str`Shows more options in the order creation form`}
name="advanceOrderMode"
/>
</FormProvider>
</div>
<div class="column" />
</div>
</section>
</div>
}

View File

@ -52,6 +52,8 @@ $tooltip-color: red;
@import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css";
@import "../../node_modules/bulma-timeline/dist/css/bulma-timeline.min.css";
@import "toggle";
.notification {
background-color: transparent;
}
@ -82,7 +84,7 @@ $tooltip-color: red;
pointer-events: none;
}
.toast > .message {
.toast>.message {
white-space: pre-wrap;
opacity: 80%;
}
@ -92,6 +94,7 @@ div {
position: relative;
pointer-events: none;
opacity: 0.5;
&:after {
// @include loader;
position: absolute;
@ -104,7 +107,7 @@ div {
}
}
input[type="checkbox"]:indeterminate + .check {
input[type="checkbox"]:indeterminate+.check {
background: red !important;
}
@ -125,6 +128,7 @@ input[type="checkbox"]:indeterminate + .check {
tr:hover .right-sticky {
background-color: hsl(0, 0%, 80%);
}
.table.is-striped tbody tr:nth-child(even):hover .right-sticky {
background-color: hsl(0, 0%, 95%);
}
@ -181,11 +185,11 @@ div[data-tooltip]::before {
position: absolute;
}
.modal-card-body > p {
.modal-card-body>p {
padding: 1em;
}
.modal-card-body > p.warning {
.modal-card-body>p.warning {
background-color: #fffbdd;
border: solid 1px #f2e9bf;
}
}

View File

@ -0,0 +1,51 @@
$green: #56c080;
.toggle {
cursor: pointer;
display: inline-block;
}
.toggle-switch {
display: inline-block;
background: #ccc;
border-radius: 16px;
width: 58px;
height: 32px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
&:before,
&:after {
content: "";
}
&:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
width: 24px;
height: 24px;
position: absolute;
top: 4px;
left: 4px;
transition: left 0.25s;
}
.toggle:hover &:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.toggle-checkbox:checked + & {
background: $green;
&:before {
left: 30px;
}
}
}
.toggle-checkbox {
position: absolute;
visibility: hidden;
}
.toggle-label {
margin-left: 5px;
position: relative;
top: 2px;
}

View File

@ -51,8 +51,8 @@ import {
stringToBytes,
TalerError,
TalerProtocolDuration,
TipCreateConfirmation,
TipCreateRequest,
RewardCreateConfirmation,
RewardCreateRequest,
TippingReserveStatus,
WalletNotification,
} from "@gnu-taler/taler-util";
@ -1751,8 +1751,8 @@ export namespace MerchantPrivateApi {
export async function giveTip(
merchantService: MerchantServiceInterface,
instance: string,
req: TipCreateRequest,
): Promise<TipCreateConfirmation> {
req: RewardCreateRequest,
): Promise<RewardCreateConfirmation> {
const reqUrl = new URL(
`private/tips`,
merchantService.makeInstanceBaseUrl(instance),

View File

@ -191,12 +191,12 @@ export async function runAgeRestrictionsMerchantTest(t: GlobalTestState) {
const walletTipping = new WalletCli(t, "age-tipping");
const ptr = await walletTipping.client.call(WalletApiOperation.PrepareTip, {
talerTipUri: tip.taler_tip_uri,
const ptr = await walletTipping.client.call(WalletApiOperation.PrepareReward, {
talerRewardUri: tip.taler_reward_uri,
});
await walletTipping.client.call(WalletApiOperation.AcceptTip, {
walletTipId: ptr.walletTipId,
await walletTipping.client.call(WalletApiOperation.AcceptReward, {
walletRewardId: ptr.walletRewardId,
});
await walletTipping.runUntilDone();

View File

@ -17,13 +17,20 @@
/**
* Imports.
*/
import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
import {
AbsoluteTime,
Duration,
NotificationType,
TransactionMajorState,
TransactionMinorState,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { defaultCoinConfig } from "../harness/denomStructures.js";
import { GlobalTestState, WalletCli } from "../harness/harness.js";
import { GlobalTestState } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironment,
withdrawViaBank,
createSimpleTestkudosEnvironmentV2,
createWalletDaemonWithClient,
withdrawViaBankV2,
} from "../harness/helpers.js";
/**
@ -32,12 +39,7 @@ import {
export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
// Set up test environment
const {
wallet: walletOne,
bank,
exchange,
merchant,
} = await createSimpleTestkudosEnvironment(
const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(
t,
defaultCoinConfig.map((x) => x("TESTKUDOS")),
{
@ -45,20 +47,29 @@ export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
},
);
const walletTwo = new WalletCli(t, "walletTwo");
const walletThree = new WalletCli(t, "walletThree");
const w1 = await createWalletDaemonWithClient(t, {
name: "w1",
persistent: true,
});
const w2 = await createWalletDaemonWithClient(t, {
name: "w2",
persistent: true,
});
const wallet1 = w1.walletClient;
const wallet2 = w2.walletClient;
{
const wallet = walletOne;
await withdrawViaBank(t, {
wallet,
const withdrawalRes = await withdrawViaBankV2(t, {
walletClient: wallet1,
bank,
exchange,
amount: "TESTKUDOS:20",
restrictAge: 13,
});
await withdrawalRes.withdrawalFinishedCond;
const purse_expiration = AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
@ -66,7 +77,7 @@ export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
),
);
const initResp = await wallet.client.call(
const initResp = await wallet1.client.call(
WalletApiOperation.InitiatePeerPushDebit,
{
partialContractTerms: {
@ -77,20 +88,35 @@ export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
},
);
await wallet.runUntilDone();
const peerPushReadyCond = wallet1.waitForNotificationCond(
(x) =>
x.type === NotificationType.TransactionStateTransition &&
x.newTxState.major === TransactionMajorState.Pending &&
x.newTxState.minor === TransactionMinorState.Ready &&
x.transactionId === initResp.transactionId,
);
const checkResp = await walletTwo.client.call(
await peerPushReadyCond;
const checkResp = await wallet2.call(
WalletApiOperation.PreparePeerPushCredit,
{
talerUri: initResp.talerUri,
},
);
await walletTwo.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, {
transactionId: checkResp.transactionId,
});
await walletTwo.runUntilDone();
const peerPullCreditDoneCond = wallet2.waitForNotificationCond(
(x) =>
x.type === NotificationType.TransactionStateTransition &&
x.newTxState.major === TransactionMajorState.Done &&
x.transactionId === checkResp.transactionId,
);
await peerPullCreditDoneCond;
}
}

View File

@ -23,6 +23,7 @@ import {
j2s,
NotificationType,
TransactionMajorState,
TransactionMinorState,
WalletNotification,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
@ -46,12 +47,14 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) {
const w1 = await createWalletDaemonWithClient(t, {
name: "w1",
persistent: true,
handleNotification(wn) {
allW1Notifications.push(wn);
},
});
const w2 = await createWalletDaemonWithClient(t, {
name: "w2",
persistent: true,
handleNotification(wn) {
allW2Notifications.push(wn);
},
@ -89,6 +92,15 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) {
},
);
const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
(x) => x.type === NotificationType.TransactionStateTransition &&
x.transactionId === resp.transactionId &&
x.newTxState.major === TransactionMajorState.Pending &&
x.newTxState.minor === TransactionMinorState.Ready,
);
await peerPullCreditReadyCond;
const checkResp = await wallet2.client.call(
WalletApiOperation.PreparePeerPullDebit,
{
@ -98,8 +110,6 @@ export async function runPeerToPeerPullTest(t: GlobalTestState) {
console.log(`checkResp: ${j2s(checkResp)}`);
// FIXME: The wallet should emit a more appropriate notification here.
// Yes, it's technically a withdrawal.
const peerPullCreditDoneCond = wallet1.waitForNotificationCond(
(x) => x.type === NotificationType.TransactionStateTransition &&
x.transactionId === resp.transactionId &&

View File

@ -99,17 +99,17 @@ export async function runTippingTest(t: GlobalTestState) {
console.log("created tip", tip);
const doTip = async (): Promise<void> => {
const ptr = await wallet.client.call(WalletApiOperation.PrepareTip, {
talerTipUri: tip.taler_tip_uri,
const ptr = await wallet.client.call(WalletApiOperation.PrepareReward, {
talerRewardUri: tip.taler_reward_uri,
});
console.log(ptr);
t.assertAmountEquals(ptr.tipAmountRaw, "TESTKUDOS:5");
t.assertAmountEquals(ptr.tipAmountEffective, "TESTKUDOS:4.85");
t.assertAmountEquals(ptr.rewardAmountRaw, "TESTKUDOS:5");
t.assertAmountEquals(ptr.rewardAmountEffective, "TESTKUDOS:4.85");
await wallet.client.call(WalletApiOperation.AcceptTip, {
walletTipId: ptr.walletTipId,
await wallet.client.call(WalletApiOperation.AcceptReward, {
walletRewardId: ptr.walletRewardId,
});
await wallet.runUntilDone();
@ -127,7 +127,7 @@ export async function runTippingTest(t: GlobalTestState) {
console.log("Transactions:", JSON.stringify(txns, undefined, 2));
t.assertDeepEqual(txns.transactions[0].type, "tip");
t.assertDeepEqual(txns.transactions[0].type, "reward");
t.assertDeepEqual(txns.transactions[0].txState.major, TransactionMajorState.Done);
t.assertAmountEquals(
txns.transactions[0].amountEffective,

View File

@ -338,8 +338,17 @@ export async function runTests(spec: TestRunSpec) {
currentChild.stdout?.pipe(harnessLogStream);
currentChild.stderr?.pipe(harnessLogStream);
const defaultTimeout = 60000;
const testTimeoutMs = testCase.timeoutMs ?? defaultTimeout;
// Default timeout when the test doesn't override it.
let defaultTimeout = 60000;
const overrideDefaultTimeout = process.env.TALER_TEST_TIMEOUT;
if (overrideDefaultTimeout) {
defaultTimeout = Number.parseInt(overrideDefaultTimeout, 10) * 1000;
}
// Set the timeout to at least be the default timeout.
const testTimeoutMs = testCase.timeoutMs
? Math.max(testCase.timeoutMs, defaultTimeout)
: defaultTimeout;
if (spec.noTimeout) {
console.log(`running ${testName}, no timeout`);

View File

@ -49,6 +49,10 @@
"node": "./lib/http-impl.node.js",
"qtart": "./lib/http-impl.qtart.js",
"default": "./lib/http-impl.missing.js"
},
"#argon2-impl": {
"node": "./lib/argon2-impl.node.js",
"default": "./lib/argon2-impl.missing.js"
}
},
"scripts": {
@ -69,11 +73,12 @@
"big-integer": "^1.6.51",
"fflate": "^0.7.4",
"jed": "^1.1.1",
"tslib": "^2.5.3"
"tslib": "^2.5.3",
"hash-wasm": "^4.9.0"
},
"ava": {
"files": [
"lib/**/*test.js"
]
}
}
}

View File

@ -0,0 +1,10 @@
export async function HashArgon2idImpl(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memorySize: number,
hashLength: number,
): Promise<Uint8Array> {
throw new Error("Method not implemented.");
}

View File

@ -0,0 +1,19 @@
import { argon2id } from "hash-wasm";
export async function HashArgon2idImpl(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memorySize: number,
hashLength: number,
): Promise<Uint8Array> {
return await argon2id({
password: password,
salt: salt,
iterations: iterations,
memorySize: memorySize,
hashLength: hashLength,
parallelism: 1,
outputType: "binary",
});
}

View File

@ -0,0 +1,18 @@
import * as impl from "#argon2-impl";
export async function hashArgon2id(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memorySize: number,
hashLength: number,
): Promise<Uint8Array> {
return await impl.HashArgon2idImpl(
password,
salt,
iterations,
memorySize,
hashLength,
);
}

View File

@ -499,7 +499,7 @@ export interface BackupRecoupGroup {
export enum BackupCoinSourceType {
Withdraw = "withdraw",
Refresh = "refresh",
Tip = "tip",
Reward = "reward",
}
/**
@ -546,7 +546,7 @@ export interface BackupRefreshCoinSource {
* Metadata about a coin obtained from a tip.
*/
export interface BackupTipCoinSource {
type: BackupCoinSourceType.Tip;
type: BackupCoinSourceType.Reward;
/**
* Wallet's identifier for the tip that this coin

View File

@ -183,7 +183,16 @@ export class HttpLibImpl implements HttpRequestLibrary {
resolve(resp);
});
res.on("error", (e) => {
reject(e);
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP response handler: ${e.message}`,
);
reject(err);
});
};
@ -197,7 +206,16 @@ export class HttpLibImpl implements HttpRequestLibrary {
}
req.on("error", (e: Error) => {
reject(e);
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP request: ${e.message}`,
);
reject(err);
});
if (reqBody) {

View File

@ -290,22 +290,22 @@ export interface ReserveStatusEntry {
active: boolean;
}
export interface TipCreateConfirmation {
export interface RewardCreateConfirmation {
// Unique tip identifier for the tip that was created.
tip_id: string;
reward_id: string;
// taler://tip URI for the tip
taler_tip_uri: string;
taler_reward_uri: string;
// URL that will directly trigger processing
// the tip when the browser is redirected to it
tip_status_url: string;
reward_status_url: string;
// when does the tip expire
tip_expiration: AbsoluteTime;
// when does the reward expire
reward_expiration: AbsoluteTime;
}
export interface TipCreateRequest {
export interface RewardCreateRequest {
// Amount that the customer should be tipped
amount: AmountString;

View File

@ -24,6 +24,7 @@
import * as nacl from "./nacl-fast.js";
import { hmacSha256, hmacSha512 } from "./kdf.js";
import bigint from "big-integer";
import * as argon2 from "./argon2.js";
import {
CoinEnvelope,
CoinPublicKeyString,
@ -69,6 +70,13 @@ interface NativeTartLib {
encodeCrock(buf: Uint8Array | ArrayBuffer): string;
decodeCrock(str: string): Uint8Array;
hash(buf: Uint8Array): Uint8Array;
hashArgon2id(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memorySize: number,
hashLength: number,
): Uint8Array;
eddsaGetPublic(buf: Uint8Array): Uint8Array;
ecdheGetPublic(buf: Uint8Array): Uint8Array;
eddsaSign(msg: Uint8Array, priv: Uint8Array): Uint8Array;
@ -253,6 +261,31 @@ export function decodeCrock(encoded: string): Uint8Array {
return out;
}
export async function hashArgon2id(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
memorySize: number,
hashLength: number,
): Promise<Uint8Array> {
if (tart) {
return tart.hashArgon2id(
password,
salt,
iterations,
memorySize,
hashLength,
);
}
return await argon2.hashArgon2id(
password,
salt,
iterations,
memorySize,
hashLength,
);
}
export function eddsaGetPublic(eddsaPriv: Uint8Array): Uint8Array {
if (tart) {
return tart.eddsaGetPublic(eddsaPriv);
@ -1221,32 +1254,14 @@ export namespace AgeRestriction {
age: number,
): Promise<AgeCommitmentProof> {
invariant((ageMask & 1) === 1);
const numPubs = countAgeGroups(ageMask) - 1;
const numPrivs = getAgeGroupIndex(ageMask, age);
const seed = getRandomBytes(32);
const pubs: Edx25519PublicKey[] = [];
const privs: Edx25519PrivateKey[] = [];
for (let i = 0; i < numPubs; i++) {
const priv = await Edx25519.keyCreate();
const pub = await Edx25519.getPublic(priv);
pubs.push(pub);
if (i < numPrivs) {
privs.push(priv);
}
}
return {
commitment: {
mask: ageMask,
publicKeys: pubs.map((x) => encodeCrock(x)),
},
proof: {
privateKeys: privs.map((x) => encodeCrock(x)),
},
};
return restrictionCommitSeeded(ageMask, age, seed);
}
const PublishedAgeRestrictionBaseKey: Edx25519PublicKey = decodeCrock(
"CH0VKFDZ2GWRWHQBBGEK9MWV5YDQVJ0RXEE0KYT3NMB69F0R96TG");
export async function restrictionCommitSeeded(
ageMask: number,
age: number,
@ -1259,19 +1274,32 @@ export namespace AgeRestriction {
const pubs: Edx25519PublicKey[] = [];
const privs: Edx25519PrivateKey[] = [];
for (let i = 0; i < numPubs; i++) {
for (let i = 0; i < numPrivs; i++) {
const privSeed = await kdfKw({
outputLength: 32,
ikm: seed,
info: stringToBytes("age-restriction-commit"),
info: stringToBytes("age-commitment"),
salt: bufferForUint32(i),
});
const priv = await Edx25519.keyCreateFromSeed(privSeed);
const pub = await Edx25519.getPublic(priv);
pubs.push(pub);
if (i < numPrivs) {
privs.push(priv);
}
privs.push(priv);
}
for (let i = numPrivs; i < numPubs; i++) {
const deriveSeed = await kdfKw({
outputLength: 32,
ikm: seed,
info: stringToBytes("age-factor"),
salt: bufferForUint32(i),
});
const pub = await Edx25519.publicKeyDerive(
PublishedAgeRestrictionBaseKey,
deriveSeed,
);
pubs.push(pub);
}
return {
@ -1571,7 +1599,9 @@ export function amountToBuffer(amount: AmountLike): Uint8Array {
return u8buf;
}
export function timestampRoundedToBuffer(ts: TalerProtocolTimestamp): Uint8Array {
export function timestampRoundedToBuffer(
ts: TalerProtocolTimestamp,
): Uint8Array {
const b = new ArrayBuffer(8);
const v = new DataView(b);
// The buffer we sign over represents the timestamp in microseconds.

View File

@ -1788,6 +1788,89 @@ export interface ExchangeRefreshRevealRequest {
old_age_commitment?: Edx25519PublicKeyEnc[];
}
export interface ExchangeAgeWithdrawRequest {
// Array of n hash codes of denomination public keys to order.
// These denominations MUST support age restriction as defined in the
// output to /keys.
// The sum of all denomination's values and fees MUST be at most the
// balance of the reserve. The balance of the reserve will be
// immediatley reduced by that amount.
denoms_h: HashCodeString[];
// n arrays of kappa entries with blinded coin envelopes. Each
// (toplevel) entry represents kappa canditates for a particular
// coin. The exchange will respond with an index gamma, which is
// the index that shall remain undisclosed during the reveal phase.
// The SHA512 hash $ACH over the blinded coin envelopes is the commitment
// that is later used as the key to the reveal-URL.
blinded_coins_evs: CoinEnvelope[][];
// The maximum age to commit to. MUST be the same as the maximum
// age value assigned to the reserve, based on its birthday date.
max_age: number;
// Signature of TALER_AgeWithdrawRequestPS created with
// the reserves's private key
// using purpose TALER_SIGNATURE_WALLET_RESERVE_AGE_WITHDRAW.
reserve_sig: EddsaSignatureString;
}
export interface ExchangeAgeWithdrawResponse {
// index of the commitments that the client doesn't
// have to disclose
noreveal_index: number;
// Signature of TALER_AgeWithdrawConfirmationPS whereby
// the exchange confirms the noreveal_index.
exchange_sig: EddsaSignatureString;
// Public EdDSA key of the exchange that was used to
// generate the signature. Should match one of the exchange's signing
// keys from /keys. Again given explicitly as the client might
// otherwise be confused by clock skew as to which signing key was used.
exchange_pub: EddsaPublicKeyString;
}
export interface ExchangeAgeWithdrawRevealRequest {
// Array of n of (kappa - 1) disclosed coin master secrets, from
// which the coins' private key, blinding, nonce (for Clause-Schnorr) and
// age-restriction is calculated.
//
// Given each coin's private key and age commitment, the exchange will
// calculate each coin's blinded hash value und use all those (disclosed)
// blinded hashes together with the non-disclosed envelopes coin_evs
// during the verification of the original age-withdraw-commitment.
disclosed_coin_secrets: AgeRestrictedCoinSecret[][];
}
// The Master key material from which the coins' private key coin_priv,
// blinding beta and nonce nonce (for Clause-Schnorr) itself are
// derived as usually in wallet-core. Given a coin's master key material,
// the age commitment for the coin MUST be derived from this private key as
// follows:
//
// Let m ∈ {1,...,M} be the maximum age group as defined in the reserve
// that the wallet can commit to.
//
// For age group $AG ∈ {1,...m}, set
// seed = HDKF(coin_secret, "age-commitment", $AG)
// p[$AG] = Edx25519_generate_private(seed)
// and calculate the corresponding Edx25519PublicKey as
// q[$AG] = Edx25519_public_from_private(p[$AG])
//
// For age groups $AG ∈ {m,...,M}, set
// f[$AG] = HDKF(coin_secret, "age-factor", $AG)
// and calculate the corresponding Edx25519PublicKey as
// q[$AG] = Edx25519_derive_public(PublishedAgeRestrictionBaseKey, f[$AG])
//
// FIXME: shall we add some flavor to this string?
export type AgeRestrictedCoinSecret = string;
export interface ExchangeAgeWithdrawRevealResponse {
// List of the exchange's blinded RSA or CS signatures on the new coins.
ev_sigs : BlindedDenominationSignature[];
}
export interface DepositSuccess {
// Optional base URL of the exchange for looking up wire transfers
// associated with this transaction. If not given,

View File

@ -21,7 +21,7 @@ import {
parsePayUri,
parseRefundUri,
parseRestoreUri,
parseTipUri,
parseRewardUri,
parseWithdrawExchangeUri,
parseWithdrawUri,
stringifyPayPushUri,
@ -161,7 +161,7 @@ test("taler refund uri parsing with instance", (t) => {
test("taler tip pickup uri", (t) => {
const url1 = "taler://tip/merchant.example.com/tipid";
const r1 = parseTipUri(url1);
const r1 = parseRewardUri(url1);
if (!r1) {
t.fail();
return;
@ -171,7 +171,7 @@ test("taler tip pickup uri", (t) => {
test("taler tip pickup uri with instance", (t) => {
const url1 = "taler://tip/merchant.example.com/instances/tipm/tipid";
const r1 = parseTipUri(url1);
const r1 = parseRewardUri(url1);
if (!r1) {
t.fail();
return;
@ -182,7 +182,7 @@ test("taler tip pickup uri with instance", (t) => {
test("taler tip pickup uri with instance and prefix", (t) => {
const url1 = "taler://tip/merchant.example.com/my/pfx/tipm/tipid";
const r1 = parseTipUri(url1);
const r1 = parseRewardUri(url1);
if (!r1) {
t.fail();
return;
@ -367,6 +367,6 @@ test("taler withdraw exchange URI with amount (stringify)", (t) => {
});
t.deepEqual(
url,
"taler://withdraw-exchange/exchange.demo.taler.net/JFX1NE38C65A5XT8VSNQXX7R7BBG4GNZ63F5T7Y6859V4J8KBKF0?a=KUDOS%3A19",
"taler://withdraw-exchange/exchange.demo.taler.net/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0?a=KUDOS%3A19",
);
});

View File

@ -26,7 +26,7 @@ export type TalerUri =
| PayPushUriResult
| BackupRestoreUri
| RefundUriResult
| TipUriResult
| RewardUriResult
| WithdrawUriResult
| ExchangeUri
| WithdrawExchangeUri
@ -60,8 +60,8 @@ export interface RefundUriResult {
orderId: string;
}
export interface TipUriResult {
type: TalerUriAction.Tip;
export interface RewardUriResult {
type: TalerUriAction.Reward;
merchantBaseUrl: string;
merchantTipId: string;
}
@ -167,7 +167,7 @@ export enum TalerUriAction {
Pay = "pay",
Withdraw = "withdraw",
Refund = "refund",
Tip = "tip",
Reward = "reward",
PayPull = "pay-pull",
PayPush = "pay-push",
PayTemplate = "pay-template",
@ -212,7 +212,7 @@ const parsers: { [A in TalerUriAction]: Parser } = {
[TalerUriAction.PayTemplate]: parsePayTemplateUri,
[TalerUriAction.Restore]: parseRestoreUri,
[TalerUriAction.Refund]: parseRefundUri,
[TalerUriAction.Tip]: parseTipUri,
[TalerUriAction.Reward]: parseRewardUri,
[TalerUriAction.Withdraw]: parseWithdrawUri,
[TalerUriAction.DevExperiment]: parseDevExperimentUri,
[TalerUriAction.Exchange]: parseExchangeUri,
@ -255,8 +255,8 @@ export function stringifyTalerUri(uri: TalerUri): string {
case TalerUriAction.Refund: {
return stringifyRefundUri(uri);
}
case TalerUriAction.Tip: {
return stringifyTipUri(uri);
case TalerUriAction.Reward: {
return stringifyRewardUri(uri);
}
case TalerUriAction.Withdraw: {
return stringifyWithdrawUri(uri);
@ -394,11 +394,11 @@ export function parsePayPullUri(s: string): PayPullUriResult | undefined {
}
/**
* Parse a taler[+http]://tip URI.
* Parse a taler[+http]://reward URI.
* Return undefined if not passed a valid URI.
*/
export function parseTipUri(s: string): TipUriResult | undefined {
const pi = parseProtoInfo(s, "tip");
export function parseRewardUri(s: string): RewardUriResult | undefined {
const pi = parseProtoInfo(s, "reward");
if (!pi) {
return undefined;
}
@ -416,7 +416,7 @@ export function parseTipUri(s: string): TipUriResult | undefined {
);
return {
type: TalerUriAction.Tip,
type: TalerUriAction.Reward,
merchantBaseUrl,
merchantTipId: tipId,
};
@ -699,12 +699,12 @@ export function stringifyRefundUri({
const { proto, path } = getUrlInfo(merchantBaseUrl);
return `${proto}://refund/${path}${orderId}`;
}
export function stringifyTipUri({
export function stringifyRewardUri({
merchantBaseUrl,
merchantTipId,
}: Omit<TipUriResult, "type">): string {
}: Omit<RewardUriResult, "type">): string {
const { proto, path } = getUrlInfo(merchantBaseUrl);
return `${proto}://tip/${path}${merchantTipId}`;
return `${proto}://reward/${path}${merchantTipId}`;
}
export function stringifyExchangeUri({
@ -767,7 +767,7 @@ function getUrlInfo(
const qp = new URLSearchParams();
let withParams = false;
Object.entries(params).forEach(([name, value]) => {
if (value) {
if (value !== undefined) {
withParams = true;
qp.append(name, value);
}

View File

@ -186,7 +186,7 @@ export type Transaction =
| TransactionWithdrawal
| TransactionPayment
| TransactionRefund
| TransactionTip
| TransactionReward
| TransactionRefresh
| TransactionDeposit
| TransactionPeerPullCredit
@ -201,7 +201,7 @@ export enum TransactionType {
Payment = "payment",
Refund = "refund",
Refresh = "refresh",
Tip = "tip",
Reward = "reward",
Deposit = "deposit",
PeerPushDebit = "peer-push-debit",
PeerPushCredit = "peer-push-credit",
@ -591,8 +591,8 @@ export interface TransactionRefund extends TransactionCommon {
paymentInfo: RefundPaymentInfo | undefined;
}
export interface TransactionTip extends TransactionCommon {
type: TransactionType.Tip;
export interface TransactionReward extends TransactionCommon {
type: TransactionType.Reward;
// Raw amount of the tip, without extra fees that apply
amountRaw: AmountString;

View File

@ -379,6 +379,54 @@ export interface Balance {
requiresUserInput: boolean;
}
export const codecForScopeInfoGlobal = (): Codec<ScopeInfoGlobal> =>
buildCodecForObject<ScopeInfoGlobal>()
.property("currency", codecForString())
.property("type", codecForConstString(ScopeType.Global))
.build("ScopeInfoGlobal");
export const codecForScopeInfoExchange = (): Codec<ScopeInfoExchange> =>
buildCodecForObject<ScopeInfoExchange>()
.property("currency", codecForString())
.property("type", codecForConstString(ScopeType.Exchange))
.property("url", codecForString())
.build("ScopeInfoExchange");
export const codecForScopeInfoAuditor = (): Codec<ScopeInfoAuditor> =>
buildCodecForObject<ScopeInfoAuditor>()
.property("currency", codecForString())
.property("type", codecForConstString(ScopeType.Auditor))
.property("url", codecForString())
.build("ScopeInfoAuditor");
export const codecForScopeInfo = (): Codec<ScopeInfo> =>
buildCodecForUnion<ScopeInfo>()
.discriminateOn("type")
.alternative(ScopeType.Global, codecForScopeInfoGlobal())
.alternative(ScopeType.Exchange, codecForScopeInfoExchange())
.alternative(ScopeType.Auditor, codecForScopeInfoAuditor())
.build("ScopeInfo");
export interface GetCurrencyInfoRequest {
scope: ScopeInfo;
}
export const codecForGetCurrencyInfoRequest =
(): Codec<GetCurrencyInfoRequest> =>
buildCodecForObject<GetCurrencyInfoRequest>()
.property("scope", codecForScopeInfo())
.build("GetCurrencyInfoRequest");
export interface GetCurrencyInfoResponse {
decimalSeparator: string;
numFractionalDigits: number;
numTinyDigits: number;
/**
* Is the currency name leading or trailing?
*/
isCurrencyNameLeading: boolean;
}
export interface InitRequest {
skipDefaults?: boolean;
}
@ -393,10 +441,19 @@ export enum ScopeType {
Auditor = "auditor",
}
export type ScopeInfo =
| { type: ScopeType.Global; currency: string }
| { type: ScopeType.Exchange; currency: string; url: string }
| { type: ScopeType.Auditor; currency: string; url: string };
export type ScopeInfoGlobal = { type: ScopeType.Global; currency: string };
export type ScopeInfoExchange = {
type: ScopeType.Exchange;
currency: string;
url: string;
};
export type ScopeInfoAuditor = {
type: ScopeType.Auditor;
currency: string;
url: string;
};
export type ScopeInfo = ScopeInfoGlobal | ScopeInfoExchange | ScopeInfoAuditor;
export interface BalancesResponse {
balances: Balance[];
@ -605,7 +662,7 @@ export interface PrepareTipResult {
*
* @deprecated use transactionId instead
*/
walletTipId: string;
walletRewardId: string;
/**
* Tip transaction ID.
@ -620,13 +677,13 @@ export interface PrepareTipResult {
/**
* Amount that the merchant gave.
*/
tipAmountRaw: AmountString;
rewardAmountRaw: AmountString;
/**
* Amount that arrived at the wallet.
* Might be lower than the raw amount due to fees.
*/
tipAmountEffective: AmountString;
rewardAmountEffective: AmountString;
/**
* Base URL of the merchant backend giving then tip.
@ -654,14 +711,14 @@ export interface AcceptTipResponse {
export const codecForPrepareTipResult = (): Codec<PrepareTipResult> =>
buildCodecForObject<PrepareTipResult>()
.property("accepted", codecForBoolean())
.property("tipAmountRaw", codecForAmountString())
.property("tipAmountEffective", codecForAmountString())
.property("rewardAmountRaw", codecForAmountString())
.property("rewardAmountEffective", codecForAmountString())
.property("exchangeBaseUrl", codecForString())
.property("merchantBaseUrl", codecForString())
.property("expirationTimestamp", codecForTimestamp)
.property("walletTipId", codecForString())
.property("walletRewardId", codecForString())
.property("transactionId", codecForString())
.build("PrepareTipResult");
.build("PrepareRewardResult");
export interface BenchmarkResult {
time: { [s: string]: number };
@ -994,6 +1051,9 @@ export interface ExchangeDetailedResponse {
}
export interface WalletCoreVersion {
/**
* @deprecated
*/
hash: string | undefined;
version: string;
exchange: string;
@ -1930,23 +1990,23 @@ export const codecForStartRefundQueryRequest =
.property("transactionId", codecForTransactionIdStr())
.build("StartRefundQueryRequest");
export interface PrepareTipRequest {
talerTipUri: string;
export interface PrepareRewardRequest {
talerRewardUri: string;
}
export const codecForPrepareTipRequest = (): Codec<PrepareTipRequest> =>
buildCodecForObject<PrepareTipRequest>()
.property("talerTipUri", codecForString())
.build("PrepareTipRequest");
export const codecForPrepareRewardRequest = (): Codec<PrepareRewardRequest> =>
buildCodecForObject<PrepareRewardRequest>()
.property("talerRewardUri", codecForString())
.build("PrepareRewardRequest");
export interface AcceptTipRequest {
walletTipId: string;
export interface AcceptRewardRequest {
walletRewardId: string;
}
export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
buildCodecForObject<AcceptTipRequest>()
.property("walletTipId", codecForString())
.build("AcceptTipRequest");
export const codecForAcceptTipRequest = (): Codec<AcceptRewardRequest> =>
buildCodecForObject<AcceptRewardRequest>()
.property("walletRewardId", codecForString())
.build("AcceptRewardRequest");
export interface FailTransactionRequest {
transactionId: TransactionIdStr;

View File

@ -651,13 +651,13 @@ walletCli
alwaysYes: args.handleUri.autoYes,
});
break;
case TalerUriAction.Tip: {
const res = await wallet.client.call(WalletApiOperation.PrepareTip, {
talerTipUri: uri,
case TalerUriAction.Reward: {
const res = await wallet.client.call(WalletApiOperation.PrepareReward, {
talerRewardUri: uri,
});
console.log("tip status", res);
await wallet.client.call(WalletApiOperation.AcceptTip, {
walletTipId: res.walletTipId,
await wallet.client.call(WalletApiOperation.AcceptReward, {
walletRewardId: res.walletRewardId,
});
break;
}

View File

@ -1,2 +1,3 @@
/lib
/coverage
/src/version.json

View File

@ -85,4 +85,4 @@
"lib/**/*test.*"
]
}
}
}

View File

@ -28,7 +28,6 @@ import {
AgeCommitmentProof,
AgeRestriction,
AmountJson,
AmountLike,
Amounts,
AmountString,
amountToBuffer,
@ -64,7 +63,6 @@ import {
hashCoinPub,
hashDenomPub,
hashTruncate32,
j2s,
kdf,
kdfKw,
keyExchangeEcdhEddsa,
@ -81,16 +79,13 @@ import {
rsaVerify,
setupTipPlanchet,
stringToBytes,
TalerProtocolDuration,
TalerProtocolTimestamp,
TalerSignaturePurpose,
timestampRoundedToBuffer,
UnblindedSignature,
validateIban,
WireFee,
WithdrawalPlanchet,
} from "@gnu-taler/taler-util";
import bigint from "big-integer";
// FIXME: Crypto should not use DB Types!
import { DenominationRecord } from "../db.js";
import {
@ -108,7 +103,6 @@ import {
EncryptContractForDepositResponse,
EncryptContractRequest,
EncryptContractResponse,
EncryptedContract,
SignDeletePurseRequest,
SignDeletePurseResponse,
SignPurseMergeRequest,
@ -726,9 +720,10 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
if (denomPub.age_mask) {
const age = req.restrictAge || AgeRestriction.AGE_UNRESTRICTED;
logger.info(`creating age-restricted planchet (age ${age})`);
maybeAcp = await AgeRestriction.restrictionCommit(
maybeAcp = await AgeRestriction.restrictionCommitSeeded(
denomPub.age_mask,
age,
stringToBytes(req.secretSeed)
);
maybeAgeCommitmentHash = AgeRestriction.hashCommitment(
maybeAcp.commitment,

View File

@ -657,6 +657,7 @@ export interface PlanchetRecord {
*/
coinIdx: number;
planchetStatus: PlanchetStatus;
lastError: TalerErrorDetail | undefined;
@ -671,13 +672,19 @@ export interface PlanchetRecord {
coinEvHash: string;
/**
* Index into the kappa-many planchet commitments per coin
* for the age-withdraw operation.
*/
ageWithdrawIdx?: number;
ageCommitmentProof?: AgeCommitmentProof;
}
export enum CoinSourceType {
Withdraw = "withdraw",
Refresh = "refresh",
Tip = "tip",
Reward = "reward",
}
export interface WithdrawCoinSource {
@ -705,13 +712,16 @@ export interface RefreshCoinSource {
oldCoinPub: string;
}
export interface TipCoinSource {
type: CoinSourceType.Tip;
walletTipId: string;
export interface RewardCoinSource {
type: CoinSourceType.Reward;
walletRewardId: string;
coinIndex: number;
}
export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource;
export type CoinSource =
| WithdrawCoinSource
| RefreshCoinSource
| RewardCoinSource;
/**
* CoinRecord as stored in the "coins" data store
@ -815,9 +825,9 @@ export interface CoinAllocation {
}
/**
* Status of a tip we got from a merchant.
* Status of a reward we got from a merchant.
*/
export interface TipRecord {
export interface RewardRecord {
/**
* Has the user accepted the tip? Only after the tip has been accepted coins
* withdrawn from the tip may be used.
@ -827,17 +837,17 @@ export interface TipRecord {
/**
* The tipped amount.
*/
tipAmountRaw: AmountString;
rewardAmountRaw: AmountString;
/**
* Effect on the balance (including fees etc).
*/
tipAmountEffective: AmountString;
rewardAmountEffective: AmountString;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
*/
tipExpiration: TalerProtocolTimestamp;
rewardExpiration: TalerProtocolTimestamp;
/**
* The exchange that will sign our coins, chosen by the merchant.
@ -863,7 +873,7 @@ export interface TipRecord {
/**
* Tip ID chosen by the wallet.
*/
walletTipId: string;
walletRewardId: string;
/**
* Secret seed used to derive planchets for this tip.
@ -871,9 +881,9 @@ export interface TipRecord {
secretSeed: string;
/**
* The merchant's identifier for this tip.
* The merchant's identifier for this reward.
*/
merchantTipId: string;
merchantRewardId: string;
createdTimestamp: TalerPreciseTimestamp;
@ -888,10 +898,10 @@ export interface TipRecord {
*/
pickedUpTimestamp: TalerPreciseTimestamp | undefined;
status: TipRecordStatus;
status: RewardRecordStatus;
}
export enum TipRecordStatus {
export enum RewardRecordStatus {
PendingPickup = 10,
SuspendidPickup = 20,
@ -1420,7 +1430,7 @@ export interface KycPendingInfo {
}
/**
* Group of withdrawal operations that need to be executed.
* (Either for a normal withdrawal or from a tip.)
* (Either for a normal {single-|batch-|age-} withdrawal or from a reward.)
*
* The withdrawal group record is only created after we know
* the coin selection we want to withdraw.
@ -2480,12 +2490,12 @@ export const WalletStoresV1 = {
]),
},
),
tips: describeStore(
"tips",
describeContents<TipRecord>({ keyPath: "walletTipId" }),
rewards: describeStore(
"rewards",
describeContents<RewardRecord>({ keyPath: "walletRewardId" }),
{
byMerchantTipIdAndBaseUrl: describeIndex("byMerchantTipIdAndBaseUrl", [
"merchantTipId",
byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [
"merchantRewardId",
"merchantBaseUrl",
]),
byStatus: describeIndex("byStatus", "status", {
@ -2510,6 +2520,11 @@ export const WalletStoresV1 = {
"planchets",
describeContents<PlanchetRecord>({ keyPath: "coinPub" }),
{
byGroupAgeCoin: describeIndex("byGroupAgeCoin", [
"withdrawalGroupId",
"ageWithdrawIdx",
"coinIdx",
]),
byGroupAndIndex: describeIndex("byGroupAndIndex", [
"withdrawalGroupId",
"coinIdx",
@ -2935,22 +2950,6 @@ export const walletDbFixups: FixupDescription[] = [
});
},
},
{
name: "TipRecordRecord_status_add",
async fn(tx): Promise<void> {
await tx.tips.iter().forEachAsync(async (r) => {
// Remove legacy transactions that don't have the totalCost field yet.
if (r.status == null) {
if (r.pickedUpTimestamp) {
r.status = TipRecordStatus.Done;
} else {
r.status = TipRecordStatus.PendingPickup;
}
await tx.tips.put(r);
}
});
},
},
{
name: "CoinAvailabilityRecord_visibleCoinCount_add",
async fn(tx): Promise<void> {

View File

@ -96,7 +96,7 @@ export async function exportBackup(
x.purchases,
x.refreshGroups,
x.backupProviders,
x.tips,
x.rewards,
x.recoupGroups,
x.withdrawalGroups,
])
@ -184,12 +184,12 @@ export async function exportBackup(
});
});
await tx.tips.iter().forEach((tip) => {
await tx.rewards.iter().forEach((tip) => {
backupTips.push({
exchange_base_url: tip.exchangeBaseUrl,
merchant_base_url: tip.merchantBaseUrl,
merchant_tip_id: tip.merchantTipId,
wallet_tip_id: tip.walletTipId,
merchant_tip_id: tip.merchantRewardId,
wallet_tip_id: tip.walletRewardId,
next_url: tip.next_url,
secret_seed: tip.secretSeed,
selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
@ -199,8 +199,8 @@ export async function exportBackup(
timestamp_finished: tip.pickedUpTimestamp,
timestamp_accepted: tip.acceptedTimestamp,
timestamp_created: tip.createdTimestamp,
timestamp_expiration: tip.tipExpiration,
tip_amount_raw: Amounts.stringify(tip.tipAmountRaw),
timestamp_expiration: tip.rewardExpiration,
tip_amount_raw: Amounts.stringify(tip.rewardAmountRaw),
selected_denoms_uid: tip.denomSelUid,
});
});
@ -244,11 +244,11 @@ export async function exportBackup(
refresh_group_id: coin.coinSource.refreshGroupId,
};
break;
case CoinSourceType.Tip:
case CoinSourceType.Reward:
bcs = {
type: BackupCoinSourceType.Tip,
type: BackupCoinSourceType.Reward,
coin_index: coin.coinSource.coinIndex,
wallet_tip_id: coin.coinSource.walletTipId,
wallet_tip_id: coin.coinSource.walletRewardId,
};
break;
case CoinSourceType.Withdraw:

View File

@ -56,7 +56,7 @@ import {
WithdrawalGroupStatus,
WithdrawalRecordType,
RefreshOperationStatus,
TipRecordStatus,
RewardRecordStatus,
} from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
@ -250,11 +250,11 @@ export async function importCoin(
refreshGroupId: backupCoin.coin_source.refresh_group_id,
};
break;
case BackupCoinSourceType.Tip:
case BackupCoinSourceType.Reward:
coinSource = {
type: CoinSourceType.Tip,
type: CoinSourceType.Reward,
coinIndex: backupCoin.coin_source.coin_index,
walletTipId: backupCoin.coin_source.wallet_tip_id,
walletRewardId: backupCoin.coin_source.wallet_tip_id,
};
break;
case BackupCoinSourceType.Withdraw:
@ -311,7 +311,7 @@ export async function importBackup(
x.purchases,
x.refreshGroups,
x.backupProviders,
x.tips,
x.rewards,
x.recoupGroups,
x.withdrawalGroups,
x.tombstones,
@ -812,13 +812,13 @@ export async function importBackup(
for (const backupTip of backupBlob.tips) {
const ts = constructTombstone({
tag: TombstoneTag.DeleteTip,
tag: TombstoneTag.DeleteReward,
walletTipId: backupTip.wallet_tip_id,
});
if (tombstoneSet.has(ts)) {
continue;
}
const existingTip = await tx.tips.get(backupTip.wallet_tip_id);
const existingTip = await tx.rewards.get(backupTip.wallet_tip_id);
if (!existingTip) {
const tipAmountRaw = Amounts.parseOrThrow(backupTip.tip_amount_raw);
const denomsSel = await getDenomSelStateFromBackup(
@ -827,22 +827,22 @@ export async function importBackup(
backupTip.exchange_base_url,
backupTip.selected_denoms,
);
await tx.tips.put({
await tx.rewards.put({
acceptedTimestamp: backupTip.timestamp_accepted,
createdTimestamp: backupTip.timestamp_created,
denomsSel,
next_url: backupTip.next_url,
exchangeBaseUrl: backupTip.exchange_base_url,
merchantBaseUrl: backupTip.exchange_base_url,
merchantTipId: backupTip.merchant_tip_id,
merchantRewardId: backupTip.merchant_tip_id,
pickedUpTimestamp: backupTip.timestamp_finished,
secretSeed: backupTip.secret_seed,
tipAmountEffective: Amounts.stringify(denomsSel.totalCoinValue),
tipAmountRaw: Amounts.stringify(tipAmountRaw),
tipExpiration: backupTip.timestamp_expiration,
walletTipId: backupTip.wallet_tip_id,
rewardAmountEffective: Amounts.stringify(denomsSel.totalCoinValue),
rewardAmountRaw: Amounts.stringify(tipAmountRaw),
rewardExpiration: backupTip.timestamp_expiration,
walletRewardId: backupTip.wallet_tip_id,
denomSelUid: backupTip.selected_denoms_uid,
status: TipRecordStatus.Done, // FIXME!
status: RewardRecordStatus.Done, // FIXME!
});
}
}
@ -863,8 +863,8 @@ export async function importBackup(
} else if (type === TombstoneTag.DeleteRefund) {
// Nothing required, will just prevent display
// in the transactions list
} else if (type === TombstoneTag.DeleteTip) {
await tx.tips.delete(rest[0]);
} else if (type === TombstoneTag.DeleteReward) {
await tx.rewards.delete(rest[0]);
} else if (type === TombstoneTag.DeleteWithdrawalGroup) {
await tx.withdrawalGroups.delete(rest[0]);
} else {

View File

@ -57,7 +57,7 @@ import {
PurchaseRecord,
RecoupGroupRecord,
RefreshGroupRecord,
TipRecord,
RewardRecord,
WithdrawalGroupRecord,
} from "../db.js";
import { makeErrorDetail, TalerError } from "@gnu-taler/taler-util";
@ -293,10 +293,10 @@ function convertTaskToTransactionId(
tag: TransactionType.Refresh,
refreshGroupId: parsedTaskId.refreshGroupId,
});
case PendingTaskType.TipPickup:
case PendingTaskType.RewardPickup:
return constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId: parsedTaskId.walletTipId,
tag: TransactionType.Reward,
walletRewardId: parsedTaskId.walletRewardId,
});
case PendingTaskType.PeerPushDebit:
return constructTransactionIdentifier({
@ -515,7 +515,7 @@ export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteReward = "delete-reward",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
@ -601,7 +601,9 @@ export function runLongpollAsync(
};
res = await reqFn(cts.token);
} catch (e) {
await storePendingTaskError(ws, retryTag, getErrorDetailFromException(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];
@ -622,7 +624,7 @@ export type ParsedTombstone =
| { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
| { tag: TombstoneTag.DeleteReserve; reservePub: string }
| { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
| { tag: TombstoneTag.DeleteTip; walletTipId: string }
| { tag: TombstoneTag.DeleteReward; walletTipId: string }
| { tag: TombstoneTag.DeletePayment; proposalId: string };
export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
@ -637,7 +639,7 @@ export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
case TombstoneTag.DeleteRefreshGroup:
return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
case TombstoneTag.DeleteTip:
case TombstoneTag.DeleteReward:
return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
default:
assertUnreachable(p);
@ -810,7 +812,7 @@ export type ParsedTaskIdentifier =
| { tag: PendingTaskType.PeerPushDebit; pursePub: string }
| { tag: PendingTaskType.Purchase; proposalId: string }
| { tag: PendingTaskType.Recoup; recoupGroupId: string }
| { tag: PendingTaskType.TipPickup; walletTipId: string }
| { tag: PendingTaskType.RewardPickup; walletRewardId: string }
| { tag: PendingTaskType.Refresh; refreshGroupId: string };
export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
@ -844,8 +846,8 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
return { tag: type, recoupGroupId: rest[0] };
case PendingTaskType.Refresh:
return { tag: type, refreshGroupId: rest[0] };
case PendingTaskType.TipPickup:
return { tag: type, walletTipId: rest[0] };
case PendingTaskType.RewardPickup:
return { tag: type, walletRewardId: rest[0] };
case PendingTaskType.Withdraw:
return { tag: type, withdrawalGroupId: rest[0] };
default:
@ -877,8 +879,8 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId {
return `${p.tag}:${p.recoupGroupId}` as TaskId;
case PendingTaskType.Refresh:
return `${p.tag}:${p.refreshGroupId}` as TaskId;
case PendingTaskType.TipPickup:
return `${p.tag}:${p.walletTipId}` as TaskId;
case PendingTaskType.RewardPickup:
return `${p.tag}:${p.walletRewardId}` as TaskId;
case PendingTaskType.Withdraw:
return `${p.tag}:${p.withdrawalGroupId}` as TaskId;
default:
@ -899,8 +901,8 @@ export namespace TaskIdentifiers {
export function forExchangeCheckRefresh(exch: ExchangeRecord): TaskId {
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}` as TaskId;
}
export function forTipPickup(tipRecord: TipRecord): TaskId {
return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}` as TaskId;
export function forTipPickup(tipRecord: RewardRecord): TaskId {
return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId;
}
export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId {
return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId;

View File

@ -436,16 +436,25 @@ async function handlePeerPullCreditCreatePurse(
logger.info(`reserve merge response: ${j2s(resp)}`);
await ws.db
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pi2 = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pi2) {
return;
}
const oldTxState = computePeerPullCreditTransactionState(pi2);
pi2.status = PeerPullPaymentInitiationStatus.PendingReady;
await tx.peerPullPaymentInitiations.put(pi2);
const newTxState = computePeerPullCreditTransactionState(pi2);
return { oldTxState, newTxState };
});
notifyTransition(ws, transactionId, transitionInfo);
return TaskRunResult.finished();
}

View File

@ -32,7 +32,7 @@ import {
PeerPushPaymentIncomingStatus,
PeerPullPaymentInitiationStatus,
WithdrawalGroupStatus,
TipRecordStatus,
RewardRecordStatus,
DepositOperationStatus,
} from "../db.js";
import {
@ -232,17 +232,17 @@ async function gatherDepositPending(
async function gatherTipPending(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{
tips: typeof WalletStoresV1.tips;
rewards: typeof WalletStoresV1.rewards;
operationRetries: typeof WalletStoresV1.operationRetries;
}>,
now: AbsoluteTime,
resp: PendingOperationsResponse,
): Promise<void> {
const range = GlobalIDB.KeyRange.bound(
TipRecordStatus.PendingPickup,
TipRecordStatus.PendingPickup,
RewardRecordStatus.PendingPickup,
RewardRecordStatus.PendingPickup,
);
await tx.tips.indexes.byStatus.iter(range).forEachAsync(async (tip) => {
await tx.rewards.indexes.byStatus.iter(range).forEachAsync(async (tip) => {
// FIXME: The tip record needs a proper status field!
if (tip.pickedUpTimestamp) {
return;
@ -252,13 +252,13 @@ async function gatherTipPending(
const timestampDue = retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
if (tip.acceptedTimestamp) {
resp.pendingOperations.push({
type: PendingTaskType.TipPickup,
type: PendingTaskType.RewardPickup,
...getPendingCommon(ws, opId, timestampDue),
givesLifeness: true,
timestampDue: retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(),
merchantBaseUrl: tip.merchantBaseUrl,
tipId: tip.walletTipId,
merchantTipId: tip.merchantTipId,
tipId: tip.walletRewardId,
merchantTipId: tip.merchantRewardId,
});
}
});
@ -494,7 +494,7 @@ export async function getPendingOperations(
x.refreshGroups,
x.coins,
x.withdrawalGroups,
x.tips,
x.rewards,
x.purchases,
x.planchets,
x.depositGroups,

View File

@ -82,7 +82,7 @@ async function putGroupAsFinished(
await tx.recoupGroups.put(recoupGroup);
}
async function recoupTipCoin(
async function recoupRewardCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
@ -482,8 +482,8 @@ async function processRecoup(
const cs = coin.coinSource;
switch (cs.type) {
case CoinSourceType.Tip:
return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
case CoinSourceType.Reward:
return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin);
case CoinSourceType.Refresh:
return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
case CoinSourceType.Withdraw:

View File

@ -31,7 +31,7 @@ import {
j2s,
Logger,
NotificationType,
parseTipUri,
parseRewardUri,
PrepareTipResult,
TalerErrorCode,
TalerPreciseTimestamp,
@ -48,8 +48,8 @@ import {
CoinRecord,
CoinSourceType,
DenominationRecord,
TipRecord,
TipRecordStatus,
RewardRecord,
RewardRecordStatus,
} from "../db.js";
import { makeErrorDetail } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
@ -84,31 +84,31 @@ const logger = new Logger("operations/tip.ts");
/**
* Get the (DD37-style) transaction status based on the
* database record of a tip.
* database record of a reward.
*/
export function computeTipTransactionStatus(
tipRecord: TipRecord,
export function computeRewardTransactionStatus(
tipRecord: RewardRecord,
): TransactionState {
switch (tipRecord.status) {
case TipRecordStatus.Done:
case RewardRecordStatus.Done:
return {
major: TransactionMajorState.Done,
};
case TipRecordStatus.Aborted:
case RewardRecordStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case TipRecordStatus.PendingPickup:
case RewardRecordStatus.PendingPickup:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Pickup,
};
case TipRecordStatus.DialogAccept:
case RewardRecordStatus.DialogAccept:
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.Proposed,
};
case TipRecordStatus.SuspendidPickup:
case RewardRecordStatus.SuspendidPickup:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Pickup,
@ -119,18 +119,18 @@ export function computeTipTransactionStatus(
}
export function computeTipTransactionActions(
tipRecord: TipRecord,
tipRecord: RewardRecord,
): TransactionAction[] {
switch (tipRecord.status) {
case TipRecordStatus.Done:
case RewardRecordStatus.Done:
return [TransactionAction.Delete];
case TipRecordStatus.Aborted:
case RewardRecordStatus.Aborted:
return [TransactionAction.Delete];
case TipRecordStatus.PendingPickup:
case RewardRecordStatus.PendingPickup:
return [TransactionAction.Suspend, TransactionAction.Fail];
case TipRecordStatus.SuspendidPickup:
case RewardRecordStatus.SuspendidPickup:
return [TransactionAction.Resume, TransactionAction.Fail];
case TipRecordStatus.DialogAccept:
case RewardRecordStatus.DialogAccept:
return [TransactionAction.Abort];
default:
assertUnreachable(tipRecord.status);
@ -141,15 +141,15 @@ export async function prepareTip(
ws: InternalWalletState,
talerTipUri: string,
): Promise<PrepareTipResult> {
const res = parseTipUri(talerTipUri);
const res = parseRewardUri(talerTipUri);
if (!res) {
throw Error("invalid taler://tip URI");
}
let tipRecord = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadOnly(async (tx) => {
return tx.tips.indexes.byMerchantTipIdAndBaseUrl.get([
return tx.rewards.indexes.byMerchantTipIdAndBaseUrl.get([
res.merchantTipId,
res.merchantBaseUrl,
]);
@ -194,44 +194,44 @@ export async function prepareTip(
const secretSeed = encodeCrock(getRandomBytes(64));
const denomSelUid = encodeCrock(getRandomBytes(32));
const newTipRecord: TipRecord = {
walletTipId: walletTipId,
const newTipRecord: RewardRecord = {
walletRewardId: walletTipId,
acceptedTimestamp: undefined,
status: TipRecordStatus.DialogAccept,
tipAmountRaw: Amounts.stringify(amount),
tipExpiration: tipPickupStatus.expiration,
status: RewardRecordStatus.DialogAccept,
rewardAmountRaw: Amounts.stringify(amount),
rewardExpiration: tipPickupStatus.expiration,
exchangeBaseUrl: tipPickupStatus.exchange_url,
next_url: tipPickupStatus.next_url,
merchantBaseUrl: res.merchantBaseUrl,
createdTimestamp: TalerPreciseTimestamp.now(),
merchantTipId: res.merchantTipId,
tipAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
merchantRewardId: res.merchantTipId,
rewardAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
denomsSel: selectedDenoms,
pickedUpTimestamp: undefined,
secretSeed,
denomSelUid,
};
await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadWrite(async (tx) => {
await tx.tips.put(newTipRecord);
await tx.rewards.put(newTipRecord);
});
tipRecord = newTipRecord;
}
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId: tipRecord.walletTipId,
tag: TransactionType.Reward,
walletRewardId: tipRecord.walletRewardId,
});
const tipStatus: PrepareTipResult = {
accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
tipAmountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
rewardAmountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
exchangeBaseUrl: tipRecord.exchangeBaseUrl,
merchantBaseUrl: tipRecord.merchantBaseUrl,
expirationTimestamp: tipRecord.tipExpiration,
tipAmountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
walletTipId: tipRecord.walletTipId,
expirationTimestamp: tipRecord.rewardExpiration,
rewardAmountEffective: Amounts.stringify(tipRecord.rewardAmountEffective),
walletRewardId: tipRecord.walletRewardId,
transactionId,
};
@ -243,25 +243,25 @@ export async function processTip(
walletTipId: string,
): Promise<TaskRunResult> {
const tipRecord = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadOnly(async (tx) => {
return tx.tips.get(walletTipId);
return tx.rewards.get(walletTipId);
});
if (!tipRecord) {
return TaskRunResult.finished();
}
switch (tipRecord.status) {
case TipRecordStatus.Aborted:
case TipRecordStatus.DialogAccept:
case TipRecordStatus.Done:
case TipRecordStatus.SuspendidPickup:
case RewardRecordStatus.Aborted:
case RewardRecordStatus.DialogAccept:
case RewardRecordStatus.Done:
case RewardRecordStatus.SuspendidPickup:
return TaskRunResult.finished();
}
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletTipId,
});
const denomsForWithdraw = tipRecord.denomsSel;
@ -300,7 +300,7 @@ export async function processTip(
}
const tipStatusUrl = new URL(
`tips/${tipRecord.merchantTipId}/pickup`,
`tips/${tipRecord.merchantRewardId}/pickup`,
tipRecord.merchantBaseUrl,
);
@ -384,9 +384,9 @@ export async function processTip(
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
coinSource: {
type: CoinSourceType.Tip,
type: CoinSourceType.Reward,
coinIndex: i,
walletTipId: walletTipId,
walletRewardId: walletTipId,
},
sourceTransactionId: transactionId,
denomPubHash: denom.denomPubHash,
@ -401,20 +401,20 @@ export async function processTip(
}
const transitionInfo = await ws.db
.mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.tips])
.mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.rewards])
.runReadWrite(async (tx) => {
const tr = await tx.tips.get(walletTipId);
const tr = await tx.rewards.get(walletTipId);
if (!tr) {
return;
}
if (tr.status !== TipRecordStatus.PendingPickup) {
if (tr.status !== RewardRecordStatus.PendingPickup) {
return;
}
const oldTxState = computeTipTransactionStatus(tr);
const oldTxState = computeRewardTransactionStatus(tr);
tr.pickedUpTimestamp = TalerPreciseTimestamp.now();
tr.status = TipRecordStatus.Done;
await tx.tips.put(tr);
const newTxState = computeTipTransactionStatus(tr);
tr.status = RewardRecordStatus.Done;
await tx.rewards.put(tr);
const newTxState = computeRewardTransactionStatus(tr);
for (const cr of newCoinRecords) {
await makeCoinAvailable(ws, tx, cr);
}
@ -432,26 +432,26 @@ export async function acceptTip(
walletTipId: string,
): Promise<AcceptTipResponse> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletTipId,
});
const dbRes = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(walletTipId);
const tipRecord = await tx.rewards.get(walletTipId);
if (!tipRecord) {
logger.error("tip not found");
return;
}
if (tipRecord.status != TipRecordStatus.DialogAccept) {
if (tipRecord.status != RewardRecordStatus.DialogAccept) {
logger.warn("Unable to accept tip in the current state");
return { tipRecord };
}
const oldTxState = computeTipTransactionStatus(tipRecord);
const oldTxState = computeRewardTransactionStatus(tipRecord);
tipRecord.acceptedTimestamp = TalerPreciseTimestamp.now();
tipRecord.status = TipRecordStatus.PendingPickup;
await tx.tips.put(tipRecord);
const newTxState = computeTipTransactionStatus(tipRecord);
tipRecord.status = RewardRecordStatus.PendingPickup;
await tx.rewards.put(tipRecord);
const newTxState = computeRewardTransactionStatus(tipRecord);
return { tipRecord, transitionInfo: { oldTxState, newTxState } };
});
@ -465,53 +465,53 @@ export async function acceptTip(
return {
transactionId: constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId: walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletTipId,
}),
next_url: tipRecord.next_url,
};
}
export async function suspendTipTransaction(
export async function suspendRewardTransaction(
ws: InternalWalletState,
walletTipId: string,
walletRewardId: string,
): Promise<void> {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.TipPickup,
walletTipId,
tag: PendingTaskType.RewardPickup,
walletRewardId: walletRewardId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletRewardId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadWrite(async (tx) => {
const tipRec = await tx.tips.get(walletTipId);
const tipRec = await tx.rewards.get(walletRewardId);
if (!tipRec) {
logger.warn(`transaction tip ${walletTipId} not found`);
logger.warn(`transaction tip ${walletRewardId} not found`);
return;
}
let newStatus: TipRecordStatus | undefined = undefined;
let newStatus: RewardRecordStatus | undefined = undefined;
switch (tipRec.status) {
case TipRecordStatus.Done:
case TipRecordStatus.SuspendidPickup:
case TipRecordStatus.Aborted:
case TipRecordStatus.DialogAccept:
case RewardRecordStatus.Done:
case RewardRecordStatus.SuspendidPickup:
case RewardRecordStatus.Aborted:
case RewardRecordStatus.DialogAccept:
break;
case TipRecordStatus.PendingPickup:
newStatus = TipRecordStatus.SuspendidPickup;
case RewardRecordStatus.PendingPickup:
newStatus = RewardRecordStatus.SuspendidPickup;
break;
default:
assertUnreachable(tipRec.status);
}
if (newStatus != null) {
const oldTxState = computeTipTransactionStatus(tipRec);
const oldTxState = computeRewardTransactionStatus(tipRec);
tipRec.status = newStatus;
const newTxState = computeTipTransactionStatus(tipRec);
await tx.tips.put(tipRec);
const newTxState = computeRewardTransactionStatus(tipRec);
await tx.rewards.put(tipRec);
return {
oldTxState,
newTxState,
@ -525,43 +525,43 @@ export async function suspendTipTransaction(
export async function resumeTipTransaction(
ws: InternalWalletState,
walletTipId: string,
walletRewardId: string,
): Promise<void> {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.TipPickup,
walletTipId,
tag: PendingTaskType.RewardPickup,
walletRewardId: walletRewardId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletRewardId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadWrite(async (tx) => {
const tipRec = await tx.tips.get(walletTipId);
if (!tipRec) {
logger.warn(`transaction tip ${walletTipId} not found`);
const rewardRec = await tx.rewards.get(walletRewardId);
if (!rewardRec) {
logger.warn(`transaction reward ${walletRewardId} not found`);
return;
}
let newStatus: TipRecordStatus | undefined = undefined;
switch (tipRec.status) {
case TipRecordStatus.Done:
case TipRecordStatus.PendingPickup:
case TipRecordStatus.Aborted:
case TipRecordStatus.DialogAccept:
let newStatus: RewardRecordStatus | undefined = undefined;
switch (rewardRec.status) {
case RewardRecordStatus.Done:
case RewardRecordStatus.PendingPickup:
case RewardRecordStatus.Aborted:
case RewardRecordStatus.DialogAccept:
break;
case TipRecordStatus.SuspendidPickup:
newStatus = TipRecordStatus.PendingPickup;
case RewardRecordStatus.SuspendidPickup:
newStatus = RewardRecordStatus.PendingPickup;
break;
default:
assertUnreachable(tipRec.status);
assertUnreachable(rewardRec.status);
}
if (newStatus != null) {
const oldTxState = computeTipTransactionStatus(tipRec);
tipRec.status = newStatus;
const newTxState = computeTipTransactionStatus(tipRec);
await tx.tips.put(tipRec);
const oldTxState = computeRewardTransactionStatus(rewardRec);
rewardRec.status = newStatus;
const newTxState = computeRewardTransactionStatus(rewardRec);
await tx.rewards.put(rewardRec);
return {
oldTxState,
newTxState,
@ -582,43 +582,43 @@ export async function failTipTransaction(
export async function abortTipTransaction(
ws: InternalWalletState,
walletTipId: string,
walletRewardId: string,
): Promise<void> {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.TipPickup,
walletTipId,
tag: PendingTaskType.RewardPickup,
walletRewardId: walletRewardId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
tag: TransactionType.Reward,
walletRewardId: walletRewardId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.tips])
.mktx((x) => [x.rewards])
.runReadWrite(async (tx) => {
const tipRec = await tx.tips.get(walletTipId);
const tipRec = await tx.rewards.get(walletRewardId);
if (!tipRec) {
logger.warn(`transaction tip ${walletTipId} not found`);
logger.warn(`transaction tip ${walletRewardId} not found`);
return;
}
let newStatus: TipRecordStatus | undefined = undefined;
let newStatus: RewardRecordStatus | undefined = undefined;
switch (tipRec.status) {
case TipRecordStatus.Done:
case TipRecordStatus.Aborted:
case TipRecordStatus.PendingPickup:
case TipRecordStatus.DialogAccept:
case RewardRecordStatus.Done:
case RewardRecordStatus.Aborted:
case RewardRecordStatus.PendingPickup:
case RewardRecordStatus.DialogAccept:
break;
case TipRecordStatus.SuspendidPickup:
newStatus = TipRecordStatus.Aborted;
case RewardRecordStatus.SuspendidPickup:
newStatus = RewardRecordStatus.Aborted;
break;
default:
assertUnreachable(tipRec.status);
}
if (newStatus != null) {
const oldTxState = computeTipTransactionStatus(tipRec);
const oldTxState = computeRewardTransactionStatus(tipRec);
tipRec.status = newStatus;
const newTxState = computeTipTransactionStatus(tipRec);
await tx.tips.put(tipRec);
const newTxState = computeRewardTransactionStatus(tipRec);
await tx.rewards.put(tipRec);
return {
oldTxState,
newTxState,

View File

@ -58,7 +58,7 @@ import {
RefreshGroupRecord,
RefreshOperationStatus,
RefundGroupRecord,
TipRecord,
RewardRecord,
WalletContractData,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
@ -107,11 +107,11 @@ import {
import {
abortTipTransaction,
failTipTransaction,
computeTipTransactionStatus,
computeRewardTransactionStatus,
resumeTipTransaction,
suspendTipTransaction,
suspendRewardTransaction,
computeTipTransactionActions,
} from "./tip.js";
} from "./reward.js";
import {
abortWithdrawalTransaction,
augmentPaytoUrisForWithdrawal,
@ -187,7 +187,7 @@ function shouldSkipSearch(
*/
const txOrder: { [t in TransactionType]: number } = {
[TransactionType.Withdrawal]: 1,
[TransactionType.Tip]: 2,
[TransactionType.Reward]: 2,
[TransactionType.Payment]: 3,
[TransactionType.PeerPullCredit]: 4,
[TransactionType.PeerPullDebit]: 5,
@ -284,12 +284,12 @@ export async function getTransactionById(
throw Error(`no tx for refresh`);
}
case TransactionType.Tip: {
const tipId = parsedTx.walletTipId;
case TransactionType.Reward: {
const tipId = parsedTx.walletRewardId;
return await ws.db
.mktx((x) => [x.tips, x.operationRetries])
.mktx((x) => [x.rewards, x.operationRetries])
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(tipId);
const tipRecord = await tx.rewards.get(tipId);
if (!tipRecord) throw Error("not found");
const retries = await tx.operationRetries.get(
@ -818,21 +818,21 @@ function buildTransactionForDeposit(
}
function buildTransactionForTip(
tipRecord: TipRecord,
tipRecord: RewardRecord,
ort?: OperationRetryRecord,
): Transaction {
checkLogicInvariant(!!tipRecord.acceptedTimestamp);
return {
type: TransactionType.Tip,
txState: computeTipTransactionStatus(tipRecord),
type: TransactionType.Reward,
txState: computeRewardTransactionStatus(tipRecord),
txActions: computeTipTransactionActions(tipRecord),
amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
amountEffective: Amounts.stringify(tipRecord.rewardAmountEffective),
amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
timestamp: tipRecord.acceptedTimestamp,
transactionId: constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId: tipRecord.walletTipId,
tag: TransactionType.Reward,
walletRewardId: tipRecord.walletRewardId,
}),
merchantBaseUrl: tipRecord.merchantBaseUrl,
...(ort?.lastError ? { error: ort.lastError } : {}),
@ -945,7 +945,7 @@ export async function getTransactions(
x.purchases,
x.contractTerms,
x.recoupGroups,
x.tips,
x.rewards,
x.tombstones,
x.withdrawalGroups,
x.refreshGroups,
@ -1200,11 +1200,11 @@ export async function getTransactions(
);
});
tx.tips.iter().forEachAsync(async (tipRecord) => {
tx.rewards.iter().forEachAsync(async (tipRecord) => {
if (
shouldSkipCurrency(
transactionsRequest,
Amounts.parseOrThrow(tipRecord.tipAmountRaw).currency,
Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
)
) {
return;
@ -1267,7 +1267,7 @@ export type ParsedTransactionIdentifier =
| { tag: TransactionType.PeerPushDebit; pursePub: string }
| { tag: TransactionType.Refresh; refreshGroupId: string }
| { tag: TransactionType.Refund; refundGroupId: string }
| { tag: TransactionType.Tip; walletTipId: string }
| { tag: TransactionType.Reward; walletRewardId: string }
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
| { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string };
@ -1291,8 +1291,8 @@ export function constructTransactionIdentifier(
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
case TransactionType.Refund:
return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
case TransactionType.Tip:
return `txn:${pTxId.tag}:${pTxId.walletTipId}` 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:
@ -1346,10 +1346,10 @@ export function parseTransactionIdentifier(
tag: TransactionType.Refund,
refundGroupId: rest[0],
};
case TransactionType.Tip:
case TransactionType.Reward:
return {
tag: TransactionType.Tip,
walletTipId: rest[0],
tag: TransactionType.Reward,
walletRewardId: rest[0],
};
case TransactionType.Withdrawal:
return {
@ -1427,10 +1427,10 @@ export async function retryTransaction(
stopLongpolling(ws, taskId);
break;
}
case TransactionType.Tip: {
case TransactionType.Reward: {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.TipPickup,
walletTipId: parsedTx.walletTipId,
tag: PendingTaskType.RewardPickup,
walletRewardId: parsedTx.walletRewardId,
});
await resetPendingTaskTimeout(ws, taskId);
stopLongpolling(ws, taskId);
@ -1522,8 +1522,8 @@ export async function suspendTransaction(
break;
case TransactionType.Refund:
throw Error("refund transactions can't be suspended or resumed");
case TransactionType.Tip:
await suspendTipTransaction(ws, tx.walletTipId);
case TransactionType.Reward:
await suspendRewardTransaction(ws, tx.walletRewardId);
break;
default:
assertUnreachable(tx);
@ -1551,8 +1551,8 @@ export async function failTransaction(
return;
case TransactionType.Refund:
throw Error("can't do cancel-aborting on refund transaction");
case TransactionType.Tip:
await failTipTransaction(ws, tx.walletTipId);
case TransactionType.Reward:
await failTipTransaction(ws, tx.walletRewardId);
return;
case TransactionType.Refresh:
await failRefreshGroup(ws, tx.refreshGroupId);
@ -1613,8 +1613,8 @@ export async function resumeTransaction(
break;
case TransactionType.Refund:
throw Error("refund transactions can't be suspended or resumed");
case TransactionType.Tip:
await resumeTipTransaction(ws, tx.walletTipId);
case TransactionType.Reward:
await resumeTipTransaction(ws, tx.walletRewardId);
break;
}
}
@ -1763,16 +1763,16 @@ export async function deleteTransaction(
return;
}
case TransactionType.Tip: {
const tipId = parsedTx.walletTipId;
case TransactionType.Reward: {
const tipId = parsedTx.walletRewardId;
await ws.db
.mktx((x) => [x.tips, x.tombstones])
.mktx((x) => [x.rewards, x.tombstones])
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(tipId);
const tipRecord = await tx.rewards.get(tipId);
if (tipRecord) {
await tx.tips.delete(tipId);
await tx.rewards.delete(tipId);
await tx.tombstones.put({
id: TombstoneTag.DeleteTip + ":" + tipId,
id: TombstoneTag.DeleteReward + ":" + tipId,
});
}
});
@ -1856,8 +1856,8 @@ export async function abortTransaction(
case TransactionType.Deposit:
await abortDepositGroup(ws, txId.depositGroupId);
break;
case TransactionType.Tip:
await abortTipTransaction(ws, txId.walletTipId);
case TransactionType.Reward:
await abortTipTransaction(ws, txId.walletRewardId);
break;
case TransactionType.Refund:
throw Error("can't abort refund transactions");

View File

@ -62,6 +62,10 @@ import {
ExchangeWithdrawResponse,
WithdrawUriInfoResponse,
ExchangeBatchWithdrawRequest,
ExchangeAgeWithdrawRequest,
ExchangeAgeWithdrawRevealRequest,
ExchangeAgeWithdrawResponse,
ExchangeAgeWithdrawRevealResponse,
TransactionState,
TransactionMajorState,
TransactionMinorState,
@ -861,6 +865,7 @@ async function processPlanchetExchangeBatchRequest(
coinIdx < wgContext.numPlanchets;
coinIdx++
) {
// FIXME[oec]: Add lookup of planchet for age-withdraw here
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
@ -923,6 +928,8 @@ async function processPlanchetExchangeBatchRequest(
// FIXME: handle individual error codes better!
// FIXME[oec]: add age-withdraw-request here
if (args.useBatchRequest) {
const reqUrl = new URL(
`reserves/${withdrawalGroup.reservePub}/batch-withdraw`,

View File

@ -33,7 +33,7 @@ export enum PendingTaskType {
Purchase = "purchase",
Refresh = "refresh",
Recoup = "recoup",
TipPickup = "tip-pickup",
RewardPickup = "reward-pickup",
Withdraw = "withdraw",
Deposit = "deposit",
Backup = "backup",
@ -144,7 +144,7 @@ export interface PendingRefreshTask {
* The wallet is picking up a tip that the user has accepted.
*/
export interface PendingTipPickupTask {
type: PendingTaskType.TipPickup;
type: PendingTaskType.RewardPickup;
tipId: string;
merchantBaseUrl: string;
merchantTipId: string;

View File

@ -34,3 +34,11 @@ export const WALLET_MERCHANT_PROTOCOL_VERSION = "2:0:1";
* Uses libtool's current:revision:age versioning.
*/
export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0";
/**
* Semver of the wallet-core implementation.
* Will be replaced with the value from package.json in a
* post-compilation step (inside lib/).
*/
export const WALLET_CORE_IMPLEMENTATION_VERSION =
"__WALLET_CORE_IMPLEMENTATION_VERSION__";

View File

@ -29,7 +29,7 @@ import {
AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest,
AcceptManualWithdrawalResult,
AcceptTipRequest,
AcceptRewardRequest,
AcceptTipResponse,
AcceptWithdrawalResponse,
AddExchangeRequest,
@ -85,8 +85,8 @@ import {
PreparePeerPushCreditRequest,
PreparePeerPushCreditResponse,
PrepareRefundRequest,
PrepareTipRequest,
PrepareTipResult,
PrepareRewardRequest as PrepareRewardRequest,
PrepareTipResult as PrepareRewardResult,
RecoveryLoadRequest,
RetryTransactionRequest,
SetCoinSuspendedRequest,
@ -114,6 +114,8 @@ import {
WithdrawUriInfoResponse,
SharePaymentRequest,
SharePaymentResult,
GetCurrencyInfoRequest,
GetCurrencyInfoResponse,
} from "@gnu-taler/taler-util";
import { AuditorTrustRecord, WalletContractData } from "./db.js";
import {
@ -178,8 +180,8 @@ export enum WalletApiOperation {
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
ForceRefresh = "forceRefresh",
PrepareTip = "prepareTip",
AcceptTip = "acceptTip",
PrepareReward = "prepareReward",
AcceptReward = "acceptReward",
ExportBackup = "exportBackup",
AddBackupProvider = "addBackupProvider",
RemoveBackupProvider = "removeBackupProvider",
@ -210,6 +212,7 @@ export enum WalletApiOperation {
ApplyDevExperiment = "applyDevExperiment",
ValidateIban = "validateIban",
TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
GetScopedCurrencyInfo = "getScopedCurrencyInfo",
}
// group: Initialization
@ -507,23 +510,23 @@ export type StartRefundQueryOp = {
response: EmptyObject;
};
// group: Tipping
// group: Rewards
/**
* Query and store information about a tip.
* Query and store information about a reward.
*/
export type PrepareTipOp = {
op: WalletApiOperation.PrepareTip;
request: PrepareTipRequest;
response: PrepareTipResult;
op: WalletApiOperation.PrepareReward;
request: PrepareRewardRequest;
response: PrepareRewardResult;
};
/**
* Accept a tip.
* Accept a reward.
*/
export type AcceptTipOp = {
op: WalletApiOperation.AcceptTip;
request: AcceptTipRequest;
op: WalletApiOperation.AcceptReward;
request: AcceptRewardRequest;
response: AcceptTipResponse;
};
@ -601,6 +604,12 @@ export type ListCurrenciesOp = {
response: WalletCurrencyInfo;
};
export type GetScopedCurrencyInfoOp = {
op: WalletApiOperation.GetScopedCurrencyInfo;
request: GetCurrencyInfoRequest;
response: GetCurrencyInfoResponse;
};
// group: Deposits
/**
@ -1023,8 +1032,8 @@ export type WalletOperations = {
[WalletApiOperation.ForceRefresh]: ForceRefreshOp;
[WalletApiOperation.DeleteTransaction]: DeleteTransactionOp;
[WalletApiOperation.RetryTransaction]: RetryTransactionOp;
[WalletApiOperation.PrepareTip]: PrepareTipOp;
[WalletApiOperation.AcceptTip]: AcceptTipOp;
[WalletApiOperation.PrepareReward]: PrepareTipOp;
[WalletApiOperation.AcceptReward]: AcceptTipOp;
[WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp;
[WalletApiOperation.StartRefundQuery]: StartRefundQueryOp;
[WalletApiOperation.ListCurrencies]: ListCurrenciesOp;
@ -1072,6 +1081,7 @@ export type WalletOperations = {
[WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp;
[WalletApiOperation.ValidateIban]: ValidateIbanOp;
[WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinal;
[WalletApiOperation.GetScopedCurrencyInfo]: GetScopedCurrencyInfoOp;
};
export type WalletCoreRequestType<

View File

@ -93,7 +93,7 @@ import {
codecForPreparePeerPullPaymentRequest,
codecForPreparePeerPushCreditRequest,
codecForPrepareRefundRequest,
codecForPrepareTipRequest,
codecForPrepareRewardRequest,
codecForResumeTransaction,
codecForRetryTransactionRequest,
codecForSetCoinSuspendedRequest,
@ -118,6 +118,8 @@ import {
sampleWalletCoreTransactions,
validateIban,
codecForSharePaymentRequest,
GetCurrencyInfoResponse,
codecForGetCurrencyInfoRequest,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
@ -249,10 +251,10 @@ import {
} from "./operations/testing.js";
import {
acceptTip,
computeTipTransactionStatus,
computeRewardTransactionStatus,
prepareTip,
processTip,
} from "./operations/tip.js";
} from "./operations/reward.js";
import {
abortTransaction,
deleteTransaction,
@ -300,6 +302,7 @@ import {
import { TimerAPI, TimerGroup } from "./util/timer.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_CORE_IMPLEMENTATION_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_MERCHANT_PROTOCOL_VERSION,
} from "./versions.js";
@ -328,7 +331,7 @@ async function callOperationHandler(
return await processRefreshGroup(ws, pending.refreshGroupId);
case PendingTaskType.Withdraw:
return await processWithdrawalGroup(ws, pending.withdrawalGroupId);
case PendingTaskType.TipPickup:
case PendingTaskType.RewardPickup:
return await processTip(ws, pending.tipId);
case PendingTaskType.Purchase:
return await processPurchase(ws, pending.proposalId);
@ -1016,12 +1019,6 @@ export async function getClientFromWalletState(
return client;
}
declare const __VERSION__: string;
declare const __GIT_HASH__: string;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
/**
* Implementation of the "wallet-core" API.
*/
@ -1355,9 +1352,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
refreshGroupId,
};
}
case WalletApiOperation.PrepareTip: {
const req = codecForPrepareTipRequest().decode(payload);
return await prepareTip(ws, req.talerTipUri);
case WalletApiOperation.PrepareReward: {
const req = codecForPrepareRewardRequest().decode(payload);
return await prepareTip(ws, req.talerRewardUri);
}
case WalletApiOperation.StartRefundQueryForUri: {
const req = codecForPrepareRefundRequest().decode(payload);
@ -1375,9 +1372,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await startQueryRefund(ws, txIdParsed.proposalId);
return {};
}
case WalletApiOperation.AcceptTip: {
case WalletApiOperation.AcceptReward: {
const req = codecForAcceptTipRequest().decode(payload);
return await acceptTip(ws, req.walletTipId);
return await acceptTip(ws, req.walletRewardId);
}
case WalletApiOperation.ExportBackupPlain: {
return exportBackup(ws);
@ -1400,6 +1397,17 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const resp = await getBackupRecovery(ws);
return resp;
}
case WalletApiOperation.GetScopedCurrencyInfo: {
// Ignore result, just validate in this mock implementation
codecForGetCurrencyInfoRequest().decode(payload);
const resp: GetCurrencyInfoResponse = {
decimalSeparator: ",",
isCurrencyNameLeading: false,
numFractionalDigits: 2,
numTinyDigits: 1,
};
return resp;
}
case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
await loadBackupRecovery(ws, req);
@ -1590,15 +1598,15 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
export function getVersion(ws: InternalWalletState): WalletCoreVersion {
const version: WalletCoreVersion = {
hash: GIT_HASH,
version: VERSION,
const result: WalletCoreVersion = {
hash: undefined,
version: WALLET_CORE_IMPLEMENTATION_VERSION,
exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
devMode: false,
};
return version;
return result;
}
/**
@ -1889,12 +1897,12 @@ class InternalWalletStateImpl implements InternalWalletState {
}
return computeRefreshTransactionState(rec);
}
case TransactionType.Tip: {
const rec = await tx.tips.get(parsedTxId.walletTipId);
case TransactionType.Reward: {
const rec = await tx.rewards.get(parsedTxId.walletRewardId);
if (!rec) {
return undefined;
}
return computeTipTransactionStatus(rec);
return computeRewardTransactionStatus(rec);
}
default:
assertUnreachable(parsedTxId);

View File

@ -7,8 +7,10 @@
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "Node16",
"resolveJsonModule": true,
"sourceMap": true,
"lib": ["es6"],
"resolvePackageJsonImports": true,
"types": ["node"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
@ -31,5 +33,8 @@
"path": "../taler-util/"
}
],
"include": ["src/**/*"]
"include": [
"src/**/*",
"src/*.json"
]
}

View File

@ -36,6 +36,7 @@
"@gnu-taler/idb-bridge": "workspace:*",
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/taler-wallet-core": "workspace:*",
"@gnu-taler/anastasis-core": "workspace:*",
"tslib": "^2.5.3"
}
}
}

View File

@ -41,6 +41,15 @@ import {
Wallet,
WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import {
reduceAction,
getBackupStartState,
getRecoveryStartState,
discoverPolicies,
mergeDiscoveryAggregate,
ReducerState,
} from "@gnu-taler/anastasis-core";
import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js";
setGlobalLogLevelFromString("trace");
@ -169,6 +178,53 @@ class NativeWalletMessageHandler {
}
}
/**
* Handle an Anastasis request from the native app.
*/
async function handleAnastasisRequest(
operation: string,
id: string,
args: any,
): Promise<CoreApiResponse> {
const wrapSuccessResponse = (result: unknown): CoreApiResponseSuccess => {
return {
type: "response",
id,
operation,
result,
};
};
let req = args ?? {};
switch (operation) {
case "anastasisReduce":
// TODO: do some input validation here
let reduceRes = await reduceAction(req.state, req.action, req.args ?? {});
// For now, this will return "success" even if the wrapped Anastasis
// response is a ReducerStateError.
return wrapSuccessResponse(reduceRes);
case "anastasisStartBackup":
return wrapSuccessResponse(await getBackupStartState());
case "anastasisStartRecovery":
return wrapSuccessResponse(await getRecoveryStartState());
case "anastasisDiscoverPolicies":
let discoverRes = await discoverPolicies(req.state, req.cursor);
let aggregatedPolicies = mergeDiscoveryAggregate(
discoverRes.policies ?? [],
req.state.discoveryState?.aggregatedPolicies ?? [],
);
return wrapSuccessResponse({
...req.state,
discoveryState: {
state: "finished",
aggregatedPolicies,
cursor: discoverRes.cursor,
},
});
}
}
export function installNativeWalletListener(): void {
setGlobalLogLevelFromString("trace");
const handler = new NativeWalletMessageHandler();
@ -190,7 +246,11 @@ export function installNativeWalletListener(): void {
let respMsg: CoreApiResponse;
try {
respMsg = await handler.handleMessage(operation, id, msg.args ?? {});
if (msg.operation.startsWith("anastasis")) {
respMsg = await handleAnastasisRequest(operation, id, msg.args ?? {});
} else {
respMsg = await handler.handleMessage(operation, id, msg.args ?? {});
}
} catch (e) {
respMsg = {
type: "error",
@ -265,7 +325,36 @@ export async function testWithLocal() {
w.wallet.stop();
}
export async function testArgon2id() {
const userIdVector = {
input_id_data: {
name: "Fleabag",
ssn: "AB123",
},
input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4",
output_id:
"YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18",
};
if (
(await userIdentifierDerive(
userIdVector.input_id_data,
userIdVector.input_server_salt,
)) != userIdVector.output_id
) {
throw Error("argon2id is not working!");
}
console.log("argon2id is working!");
}
// @ts-ignore
globalThis.testWithGv = testWithGv;
// @ts-ignore
globalThis.testWithLocal = testWithLocal;
// @ts-ignore
globalThis.testArgon2id = testArgon2id;
// @ts-ignore
globalThis.testReduceAction = reduceAction;
// @ts-ignore
globalThis.testDiscoverPolicies = discoverPolicies;

View File

@ -146,7 +146,7 @@ const talerUriActionToPageName: {
} = {
[TalerUriAction.Withdraw]: "ctaWithdraw",
[TalerUriAction.Pay]: "ctaPay",
[TalerUriAction.Tip]: "ctaTips",
[TalerUriAction.Reward]: "ctaTips",
[TalerUriAction.Refund]: "ctaRefund",
[TalerUriAction.PayPull]: "ctaInvoicePay",
[TalerUriAction.PayPush]: "ctaTransferPickup",

View File

@ -134,7 +134,7 @@ export function HistoryItem(props: { tx: Transaction }): VNode {
}
/>
);
case TransactionType.Tip:
case TransactionType.Reward:
return (
<Layout
id={tx.transactionId}

Some files were not shown because too many files have changed in this diff Show More