gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated (05cdbfb5 -> f11483b5)


From: gnunet
Subject: [taler-wallet-core] branch master updated (05cdbfb5 -> f11483b5)
Date: Tue, 12 Jul 2022 17:51:13 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a change to branch master
in repository wallet-core.

    from 05cdbfb5 -typo
     new b214934b wallet-core: P2P push payments (still incomplete)
     new f11483b5 wallet-core: implement accepting p2p push payments

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 packages/anastasis-core/src/crypto.ts              |   6 +-
 packages/taler-util/package.json                   |   1 +
 packages/taler-util/src/index.ts                   |   1 +
 packages/taler-util/src/talerCrypto.test.ts        |  28 +-
 packages/taler-util/src/talerCrypto.ts             | 255 ++++++++---
 packages/taler-util/src/talerTypes.ts              |  84 +++-
 packages/taler-util/src/walletTypes.ts             |  62 ++-
 packages/taler-wallet-cli/src/harness/harness.ts   |  30 ++
 packages/taler-wallet-cli/src/index.ts             |   9 +-
 .../{test-payment-zero.ts => test-peer-to-peer.ts} |  63 +--
 .../src/integrationtests/testrunner.ts             |   2 +
 .../src/crypto/cryptoImplementation.ts             | 252 ++++++++++-
 .../taler-wallet-core/src/crypto/cryptoTypes.ts    |  81 ++++
 packages/taler-wallet-core/src/db.ts               | 104 ++++-
 .../src/operations/backup/import.ts                |  52 ++-
 packages/taler-wallet-core/src/operations/pay.ts   |   2 +-
 .../src/operations/peer-to-peer.ts                 | 464 +++++++++++++++++++++
 .../src/util/contractTerms.test.ts                 | 122 ------
 .../taler-wallet-core/src/util/contractTerms.ts    | 230 ----------
 packages/taler-wallet-core/src/wallet-api-types.ts |  20 +
 packages/taler-wallet-core/src/wallet.ts           |  21 +
 pnpm-lock.yaml                                     |   2 +
 22 files changed, 1380 insertions(+), 511 deletions(-)
 copy packages/taler-wallet-cli/src/integrationtests/{test-payment-zero.ts => 
test-peer-to-peer.ts} (55%)
 create mode 100644 packages/taler-wallet-core/src/operations/peer-to-peer.ts
 delete mode 100644 packages/taler-wallet-core/src/util/contractTerms.test.ts
 delete mode 100644 packages/taler-wallet-core/src/util/contractTerms.ts

diff --git a/packages/anastasis-core/src/crypto.ts 
b/packages/anastasis-core/src/crypto.ts
index 815f84c1..5e45f995 100644
--- a/packages/anastasis-core/src/crypto.ts
+++ b/packages/anastasis-core/src/crypto.ts
@@ -227,11 +227,11 @@ async function anastasisDecrypt(
   const nonceBuf = ctBuf.slice(0, nonceSize);
   const enc = ctBuf.slice(nonceSize);
   const key = await deriveKey(keySeed, encodeCrock(nonceBuf), salt);
-  const cipherText = secretbox_open(enc, nonceBuf, key);
-  if (!cipherText) {
+  const clearText = secretbox_open(enc, nonceBuf, key);
+  if (!clearText) {
     throw Error("could not decrypt");
   }
-  return encodeCrock(cipherText);
+  return encodeCrock(clearText);
 }
 
 export const asOpaque = (x: string): OpaqueData => x;
diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json
index 42ca8cb2..af87742c 100644
--- a/packages/taler-util/package.json
+++ b/packages/taler-util/package.json
@@ -38,6 +38,7 @@
   },
   "dependencies": {
     "big-integer": "^1.6.51",
+    "fflate": "^0.7.3",
     "jed": "^1.1.1",
     "tslib": "^2.3.1"
   },
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
index 199218d6..cf48ba80 100644
--- a/packages/taler-util/src/index.ts
+++ b/packages/taler-util/src/index.ts
@@ -32,3 +32,4 @@ export {
 } from "./nacl-fast.js";
 export { RequestThrottler } from "./RequestThrottler.js";
 export * from "./CancellationToken.js";
+export * from "./contractTerms.js";
diff --git a/packages/taler-util/src/talerCrypto.test.ts 
b/packages/taler-util/src/talerCrypto.test.ts
index 5e8f37d8..b4a0106f 100644
--- a/packages/taler-util/src/talerCrypto.test.ts
+++ b/packages/taler-util/src/talerCrypto.test.ts
@@ -374,7 +374,7 @@ test("taler age restriction crypto", async (t) => {
   const priv1 = await Edx25519.keyCreate();
   const pub1 = await Edx25519.getPublic(priv1);
 
-  const seed = encodeCrock(getRandomBytes(32));
+  const seed = getRandomBytes(32);
 
   const priv2 = await Edx25519.privateKeyDerive(priv1, seed);
   const pub2 = await Edx25519.publicKeyDerive(pub1, seed);
@@ -392,18 +392,18 @@ test("edx signing", async (t) => {
 
   const sig = nacl.crypto_edx25519_sign_detached(
     msg,
-    decodeCrock(priv1),
-    decodeCrock(pub1),
+    priv1,
+    pub1,
   );
 
   t.true(
-    nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)),
+    nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
   );
 
   sig[0]++;
 
   t.false(
-    nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)),
+    nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1),
   );
 });
 
@@ -421,13 +421,19 @@ test("edx test vector", async (t) => {
   };
 
   {
-    const pub1Prime = await Edx25519.getPublic(tv.priv1_edx);
-    t.is(pub1Prime, tv.pub1_edx);
+    const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx));
+    t.is(pub1Prime, decodeCrock(tv.pub1_edx));
   }
 
-  const pub2Prime = await Edx25519.publicKeyDerive(tv.pub1_edx, tv.seed);
-  t.is(pub2Prime, tv.pub2_edx);
+  const pub2Prime = await Edx25519.publicKeyDerive(
+    decodeCrock(tv.pub1_edx),
+    decodeCrock(tv.seed),
+  );
+  t.is(pub2Prime, decodeCrock(tv.pub2_edx));
 
-  const priv2Prime = await Edx25519.privateKeyDerive(tv.priv1_edx, tv.seed);
-  t.is(priv2Prime, tv.priv2_edx);
+  const priv2Prime = await Edx25519.privateKeyDerive(
+    decodeCrock(tv.priv1_edx),
+    decodeCrock(tv.seed),
+  );
+  t.is(priv2Prime, decodeCrock(tv.priv2_edx));
 });
diff --git a/packages/taler-util/src/talerCrypto.ts 
b/packages/taler-util/src/talerCrypto.ts
index e2360b09..5de767dd 100644
--- a/packages/taler-util/src/talerCrypto.ts
+++ b/packages/taler-util/src/talerCrypto.ts
@@ -25,7 +25,6 @@ import * as nacl from "./nacl-fast.js";
 import { kdf, kdfKw } from "./kdf.js";
 import bigint from "big-integer";
 import {
-  Base32String,
   CoinEnvelope,
   CoinPublicKeyString,
   DenominationPubKey,
@@ -33,11 +32,29 @@ import {
   HashCodeString,
 } from "./talerTypes.js";
 import { Logger } from "./logging.js";
+import { secretbox } from "./nacl-fast.js";
+import * as fflate from "fflate";
+import { canonicalJson } from "./helpers.js";
+
+export type Flavor<T, FlavorT extends string> = T & {
+  _flavor?: `taler.${FlavorT}`;
+};
+
+export type FlavorP<T, FlavorT extends string, S extends number> = T & {
+  _flavor?: `taler.${FlavorT}`;
+  _size?: S;
+};
 
 export function getRandomBytes(n: number): Uint8Array {
   return nacl.randomBytes(n);
 }
 
+export function getRandomBytesF<T extends number, N extends string>(
+  n: T,
+): FlavorP<Uint8Array, N, T> {
+  return nacl.randomBytes(n);
+}
+
 const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
 
 class EncodingError extends Error {
@@ -157,8 +174,8 @@ export function keyExchangeEddsaEcdhe(
 }
 
 export function keyExchangeEcdheEddsa(
-  ecdhePriv: Uint8Array,
-  eddsaPub: Uint8Array,
+  ecdhePriv: Uint8Array & MaterialEcdhePriv,
+  eddsaPub: Uint8Array & MaterialEddsaPub,
 ): Uint8Array {
   const curve25519Pub = nacl.sign_ed25519_pk_to_curve25519(eddsaPub);
   const x = nacl.scalarMult(ecdhePriv, curve25519Pub);
@@ -679,7 +696,8 @@ export function hashDenomPub(pub: DenominationPubKey): 
Uint8Array {
     return nacl.hash(uint8ArrayBuf);
   } else {
     throw Error(
-      `unsupported cipher (${(pub as DenominationPubKey).cipher
+      `unsupported cipher (${
+        (pub as DenominationPubKey).cipher
       }), unable to hash`,
     );
   }
@@ -773,6 +791,11 @@ export enum TalerSignaturePurpose {
   WALLET_COIN_LINK = 1204,
   WALLET_COIN_RECOUP_REFRESH = 1206,
   WALLET_AGE_ATTESTATION = 1207,
+  WALLET_PURSE_CREATE = 1210,
+  WALLET_PURSE_DEPOSIT = 1211,
+  WALLET_PURSE_MERGE = 1213,
+  WALLET_ACCOUNT_MERGE = 1214,
+  WALLET_PURSE_ECONTRACT = 1216,
   EXCHANGE_CONFIRM_RECOUP = 1039,
   EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
   ANASTASIS_POLICY_UPLOAD = 1400,
@@ -780,10 +803,26 @@ export enum TalerSignaturePurpose {
   SYNC_BACKUP_UPLOAD = 1450,
 }
 
+export const enum WalletAccountMergeFlags {
+  /**
+   * Not a legal mode!
+   */
+  None = 0,
+
+  /**
+   * We are merging a fully paid-up purse into a reserve.
+   */
+  MergeFullyPaidPurse = 1,
+
+  CreateFromPurseQuota = 2,
+
+  CreateWithPurseFee = 3,
+}
+
 export class SignaturePurposeBuilder {
   private chunks: Uint8Array[] = [];
 
-  constructor(private purposeNum: number) { }
+  constructor(private purposeNum: number) {}
 
   put(bytes: Uint8Array): SignaturePurposeBuilder {
     this.chunks.push(Uint8Array.from(bytes));
@@ -813,19 +852,10 @@ export function buildSigPS(purposeNum: number): 
SignaturePurposeBuilder {
   return new SignaturePurposeBuilder(purposeNum);
 }
 
-export type Flavor<T, FlavorT extends string> = T & {
-  _flavor?: `taler.${FlavorT}`;
-};
-
-export type FlavorP<T, FlavorT extends string, S extends number> = T & {
-  _flavor?: `taler.${FlavorT}`;
-  _size?: S;
-};
-
-export type OpaqueData = Flavor<string, "OpaqueData">;
-export type Edx25519PublicKey = FlavorP<string, "Edx25519PublicKey", 32>;
-export type Edx25519PrivateKey = FlavorP<string, "Edx25519PrivateKey", 64>;
-export type Edx25519Signature = FlavorP<string, "Edx25519Signature", 64>;
+export type OpaqueData = Flavor<Uint8Array, any>;
+export type Edx25519PublicKey = FlavorP<Uint8Array, "Edx25519PublicKey", 32>;
+export type Edx25519PrivateKey = FlavorP<Uint8Array, "Edx25519PrivateKey", 64>;
+export type Edx25519Signature = FlavorP<Uint8Array, "Edx25519Signature", 64>;
 
 /**
  * Convert a big integer to a fixed-size, little-endian array.
@@ -857,19 +887,17 @@ export namespace Edx25519 {
   export async function keyCreateFromSeed(
     seed: OpaqueData,
   ): Promise<Edx25519PrivateKey> {
-    return encodeCrock(
-      nacl.crypto_edx25519_private_key_create_from_seed(decodeCrock(seed)),
-    );
+    return nacl.crypto_edx25519_private_key_create_from_seed(seed);
   }
 
   export async function keyCreate(): Promise<Edx25519PrivateKey> {
-    return encodeCrock(nacl.crypto_edx25519_private_key_create());
+    return nacl.crypto_edx25519_private_key_create();
   }
 
   export async function getPublic(
     priv: Edx25519PrivateKey,
   ): Promise<Edx25519PublicKey> {
-    return encodeCrock(nacl.crypto_edx25519_get_public(decodeCrock(priv)));
+    return nacl.crypto_edx25519_get_public(priv);
   }
 
   export function sign(
@@ -885,12 +913,12 @@ export namespace Edx25519 {
   ): Promise<OpaqueData> {
     const res = kdfKw({
       outputLength: 64,
-      salt: decodeCrock(seed),
-      ikm: decodeCrock(pub),
-      info: stringToBytes("edx25519-derivation"),
+      salt: seed,
+      ikm: pub,
+      info: stringToBytes("edx2559-derivation"),
     });
 
-    return encodeCrock(res);
+    return res;
   }
 
   export async function privateKeyDerive(
@@ -898,21 +926,17 @@ export namespace Edx25519 {
     seed: OpaqueData,
   ): Promise<Edx25519PrivateKey> {
     const pub = await getPublic(priv);
-    const privDec = decodeCrock(priv);
+    const privDec = priv;
     const a = bigintFromNaclArr(privDec.subarray(0, 32));
     const factorEnc = await deriveFactor(pub, seed);
-    const factorModL = bigintFromNaclArr(decodeCrock(factorEnc)).mod(L);
+    const factorModL = bigintFromNaclArr(factorEnc).mod(L);
 
     const aPrime = a.divide(8).multiply(factorModL).mod(L).multiply(8).mod(L);
     const bPrime = nacl
-      .hash(
-        typedArrayConcat([privDec.subarray(32, 64), decodeCrock(factorEnc)]),
-      )
+      .hash(typedArrayConcat([privDec.subarray(32, 64), factorEnc]))
       .subarray(0, 32);
 
-    const newPriv = encodeCrock(
-      typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]),
-    );
+    const newPriv = typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]);
 
     return newPriv;
   }
@@ -922,14 +946,9 @@ export namespace Edx25519 {
     seed: OpaqueData,
   ): Promise<Edx25519PublicKey> {
     const factorEnc = await deriveFactor(pub, seed);
-    const factorReduced = nacl.crypto_core_ed25519_scalar_reduce(
-      decodeCrock(factorEnc),
-    );
-    const res = nacl.crypto_scalarmult_ed25519_noclamp(
-      factorReduced,
-      decodeCrock(pub),
-    );
-    return encodeCrock(res);
+    const factorReduced = nacl.crypto_core_ed25519_scalar_reduce(factorEnc);
+    const res = nacl.crypto_scalarmult_ed25519_noclamp(factorReduced, pub);
+    return res;
   }
 }
 
@@ -965,7 +984,7 @@ export namespace AgeRestriction {
   export function hashCommitment(ac: AgeCommitment): HashCodeString {
     const hc = new nacl.HashState();
     for (const pub of ac.publicKeys) {
-      hc.update(decodeCrock(pub));
+      hc.update(pub);
     }
     return encodeCrock(hc.finish().subarray(0, 32));
   }
@@ -1089,16 +1108,12 @@ export namespace AgeRestriction {
     const group = getAgeGroupIndex(commitmentProof.commitment.mask, age);
     if (group === 0) {
       // No attestation required.
-      return encodeCrock(new Uint8Array(64));
+      return new Uint8Array(64);
     }
     const priv = commitmentProof.proof.privateKeys[group - 1];
     const pub = commitmentProof.commitment.publicKeys[group - 1];
-    const sig = nacl.crypto_edx25519_sign_detached(
-      d,
-      decodeCrock(priv),
-      decodeCrock(pub),
-    );
-    return encodeCrock(sig);
+    const sig = nacl.crypto_edx25519_sign_detached(d, priv, pub);
+    return sig;
   }
 
   export function commitmentVerify(
@@ -1116,10 +1131,138 @@ export namespace AgeRestriction {
       return true;
     }
     const pub = commitment.publicKeys[group - 1];
-    return nacl.crypto_edx25519_sign_detached_verify(
-      d,
-      decodeCrock(sig),
-      decodeCrock(pub),
-    );
+    return nacl.crypto_edx25519_sign_detached_verify(d, decodeCrock(sig), pub);
   }
 }
+
+// FIXME: make it a branded type!
+type EncryptionNonce = FlavorP<Uint8Array, "EncryptionNonce", 24>;
+
+async function deriveKey(
+  keySeed: OpaqueData,
+  nonce: EncryptionNonce,
+  salt: string,
+): Promise<Uint8Array> {
+  return kdfKw({
+    outputLength: 32,
+    salt: nonce,
+    ikm: keySeed,
+    info: stringToBytes(salt),
+  });
+}
+
+async function encryptWithDerivedKey(
+  nonce: EncryptionNonce,
+  keySeed: OpaqueData,
+  plaintext: OpaqueData,
+  salt: string,
+): Promise<OpaqueData> {
+  const key = await deriveKey(keySeed, nonce, salt);
+  const cipherText = secretbox(plaintext, nonce, key);
+  return typedArrayConcat([nonce, cipherText]);
+}
+
+const nonceSize = 24;
+
+async function decryptWithDerivedKey(
+  ciphertext: OpaqueData,
+  keySeed: OpaqueData,
+  salt: string,
+): Promise<OpaqueData> {
+  const ctBuf = ciphertext;
+  const nonceBuf = ctBuf.slice(0, nonceSize);
+  const enc = ctBuf.slice(nonceSize);
+  const key = await deriveKey(keySeed, nonceBuf, salt);
+  const clearText = nacl.secretbox_open(enc, nonceBuf, key);
+  if (!clearText) {
+    throw Error("could not decrypt");
+  }
+  return clearText;
+}
+
+enum ContractFormatTag {
+  PaymentOffer = 0,
+  PaymentRequest = 1,
+}
+
+type MaterialEddsaPub = {
+  _materialType?: "eddsa-pub";
+  _size?: 32;
+};
+
+type MaterialEddsaPriv = {
+  _materialType?: "ecdhe-priv";
+  _size?: 32;
+};
+
+type MaterialEcdhePub = {
+  _materialType?: "ecdhe-pub";
+  _size?: 32;
+};
+
+type MaterialEcdhePriv = {
+  _materialType?: "ecdhe-priv";
+  _size?: 32;
+};
+
+type PursePublicKey = FlavorP<Uint8Array, "PursePublicKey", 32> &
+  MaterialEddsaPub;
+
+type ContractPrivateKey = FlavorP<Uint8Array, "ContractPrivateKey", 32> &
+  MaterialEcdhePriv;
+
+type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> &
+  MaterialEddsaPriv;
+
+export function encryptContractForMerge(
+  pursePub: PursePublicKey,
+  contractPriv: ContractPrivateKey,
+  mergePriv: MergePrivateKey,
+  contractTerms: any,
+): Promise<OpaqueData> {
+  const contractTermsCanon = canonicalJson(contractTerms) + "\0";
+  const contractTermsBytes = stringToBytes(contractTermsCanon);
+  const contractTermsCompressed = fflate.zlibSync(contractTermsBytes);
+  const data = typedArrayConcat([
+    bufferForUint32(ContractFormatTag.PaymentOffer),
+    bufferForUint32(contractTermsBytes.length),
+    mergePriv,
+    contractTermsCompressed,
+  ]);
+  const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
+  return encryptWithDerivedKey(
+    getRandomBytesF(24),
+    key,
+    data,
+    "p2p-merge-contract",
+  );
+}
+
+export interface DecryptForMergeResult {
+  contractTerms: any;
+  mergePriv: Uint8Array;
+}
+
+export async function decryptContractForMerge(
+  enc: OpaqueData,
+  pursePub: PursePublicKey,
+  contractPriv: ContractPrivateKey,
+): Promise<DecryptForMergeResult> {
+  const key = keyExchangeEcdheEddsa(contractPriv, pursePub);
+  const dec = await decryptWithDerivedKey(enc, key, "p2p-merge-contract");
+  const mergePriv = dec.slice(8, 8 + 32);
+  const contractTermsCompressed = dec.slice(8 + 32);
+  const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed);
+  // Slice of the '\0' at the end and decode to a string
+  const contractTermsString = bytesToString(
+    contractTermsBuf.slice(0, contractTermsBuf.length - 1),
+  );
+  return {
+    mergePriv: mergePriv,
+    contractTerms: JSON.parse(contractTermsString),
+  };
+}
+
+export function encryptContractForDeposit() {
+  throw Error("not implemented");
+}
diff --git a/packages/taler-util/src/talerTypes.ts 
b/packages/taler-util/src/talerTypes.ts
index 7fc3fcba..d4de8c37 100644
--- a/packages/taler-util/src/talerTypes.ts
+++ b/packages/taler-util/src/talerTypes.ts
@@ -565,8 +565,8 @@ export interface MerchantAbortPayRefundDetails {
   refund_amount: string;
 
   /**
-  * Fee for the refund.
-  */
+   * Fee for the refund.
+   */
   refund_fee: string;
 
   /**
@@ -1794,3 +1794,83 @@ export const codecForDepositSuccess = (): 
Codec<DepositSuccess> =>
     .property("exchange_timestamp", codecForTimestamp)
     .property("transaction_base_url", codecOptional(codecForString()))
     .build("DepositSuccess");
+
+export interface PurseDeposit {
+  /**
+   * Amount to be deposited, can be a fraction of the
+   * coin's total value.
+   */
+  amount: AmountString;
+
+  /**
+   * Hash of denomination RSA key with which the coin is signed.
+   */
+  denom_pub_hash: HashCodeString;
+
+  /**
+   * Exchange's unblinded RSA signature of the coin.
+   */
+  ub_sig: UnblindedSignature;
+
+  /**
+   * Age commitment hash for the coin, if the denomination is age-restricted.
+   */
+  h_age_commitment?: HashCodeString;
+
+  // FIXME-Oec: proof of age is missing.
+
+  /**
+   * Signature over TALER_PurseDepositSignaturePS
+   * of purpose TALER_SIGNATURE_WALLET_PURSE_DEPOSIT
+   * made by the customer with the
+   * coin's private key.
+   */
+  coin_sig: EddsaSignatureString;
+
+  /**
+   * Public key of the coin being deposited into the purse.
+   */
+  coin_pub: EddsaPublicKeyString;
+}
+
+export interface ExchangePurseMergeRequest {
+  // payto://-URI of the account the purse is to be merged into.
+  // Must be of the form: 'payto://taler/$EXCHANGE_URL/$RESERVE_PUB'.
+  payto_uri: string;
+
+  // EdDSA signature of the account/reserve affirming the merge
+  // over a TALER_AccountMergeSignaturePS.
+  // Must be of purpose TALER_SIGNATURE_ACCOUNT_MERGE
+  reserve_sig: EddsaSignatureString;
+
+  // EdDSA signature of the purse private key affirming the merge
+  // over a TALER_PurseMergeSignaturePS.
+  // Must be of purpose TALER_SIGNATURE_PURSE_MERGE.
+  merge_sig: EddsaSignatureString;
+
+  // Client-side timestamp of when the merge request was made.
+  merge_timestamp: TalerProtocolTimestamp;
+}
+
+export interface ExchangeGetContractResponse {
+  purse_pub: string;
+  econtract_sig: string;
+  econtract: string;
+}
+
+export const codecForExchangeGetContractResponse =
+  (): Codec<ExchangeGetContractResponse> =>
+    buildCodecForObject<ExchangeGetContractResponse>()
+      .property("purse_pub", codecForString())
+      .property("econtract_sig", codecForString())
+      .property("econtract", codecForString())
+      .build("ExchangeGetContractResponse");
+
+/**
+ * Contract terms between two wallets (as opposed to a merchant and wallet).
+ */
+export interface PeerContractTerms {
+  amount: AmountString;
+  summary: string;
+  purse_expiration: TalerProtocolTimestamp;
+}
diff --git a/packages/taler-util/src/walletTypes.ts 
b/packages/taler-util/src/walletTypes.ts
index 2e5dd418..245b5654 100644
--- a/packages/taler-util/src/walletTypes.ts
+++ b/packages/taler-util/src/walletTypes.ts
@@ -32,10 +32,7 @@ import {
   codecForAmountJson,
   codecForAmountString,
 } from "./amounts.js";
-import {
-  codecForTimestamp,
-  TalerProtocolTimestamp,
-} from "./time.js";
+import { codecForTimestamp, TalerProtocolTimestamp } from "./time.js";
 import {
   buildCodecForObject,
   codecForString,
@@ -1230,15 +1227,14 @@ export interface ForcedCoinSel {
 }
 
 export interface TestPayResult {
-  payCoinSelection: PayCoinSelection,
+  payCoinSelection: PayCoinSelection;
 }
 
-
 /**
  * Result of selecting coins, contains the exchange, and selected
  * coins with their denomination.
  */
- export interface PayCoinSelection {
+export interface PayCoinSelection {
   /**
    * Amount requested by the merchant.
    */
@@ -1263,4 +1259,54 @@ export interface TestPayResult {
    * How much of the deposit fees is the customer paying?
    */
   customerDepositFees: AmountJson;
-}
\ No newline at end of file
+}
+
+export interface InitiatePeerPushPaymentRequest {
+  amount: AmountString;
+  partialContractTerms: any;
+}
+
+export interface InitiatePeerPushPaymentResponse {
+  exchangeBaseUrl: string;
+  pursePub: string;
+  mergePriv: string;
+  contractPriv: string;
+}
+
+export const codecForInitiatePeerPushPaymentRequest =
+  (): Codec<InitiatePeerPushPaymentRequest> =>
+    buildCodecForObject<InitiatePeerPushPaymentRequest>()
+      .property("amount", codecForAmountString())
+      .property("partialContractTerms", codecForAny())
+      .build("InitiatePeerPushPaymentRequest");
+
+export interface CheckPeerPushPaymentRequest {
+  exchangeBaseUrl: string;
+  pursePub: string;
+  contractPriv: string;
+}
+
+export interface CheckPeerPushPaymentResponse {
+  contractTerms: any;
+  amount: AmountString;
+}
+
+export const codecForCheckPeerPushPaymentRequest =
+  (): Codec<CheckPeerPushPaymentRequest> =>
+    buildCodecForObject<CheckPeerPushPaymentRequest>()
+      .property("pursePub", codecForString())
+      .property("contractPriv", codecForString())
+      .property("exchangeBaseUrl", codecForString())
+      .build("CheckPeerPushPaymentRequest");
+
+export interface AcceptPeerPushPaymentRequest {
+  exchangeBaseUrl: string;
+  pursePub: string;
+}
+
+export const codecForAcceptPeerPushPaymentRequest =
+  (): Codec<AcceptPeerPushPaymentRequest> =>
+    buildCodecForObject<AcceptPeerPushPaymentRequest>()
+      .property("pursePub", codecForString())
+      .property("exchangeBaseUrl", codecForString())
+      .build("AcceptPeerPushPaymentRequest");
diff --git a/packages/taler-wallet-cli/src/harness/harness.ts 
b/packages/taler-wallet-cli/src/harness/harness.ts
index 74a52334..3b58219b 100644
--- a/packages/taler-wallet-cli/src/harness/harness.ts
+++ b/packages/taler-wallet-cli/src/harness/harness.ts
@@ -1296,6 +1296,36 @@ export class ExchangeService implements 
ExchangeServiceInterface {
         );
       }
     }
+
+    await runCommand(
+      this.globalState,
+      "exchange-offline",
+      "taler-exchange-offline",
+      [
+        "-c",
+        this.configFilename,
+        "global-fee",
+        // year
+        "now",
+        // history fee
+        `${this.exchangeConfig.currency}:0.01`,
+        // kyc fee
+        `${this.exchangeConfig.currency}:0.01`,
+        // account fee
+        `${this.exchangeConfig.currency}:0.01`,
+        // purse fee
+        `${this.exchangeConfig.currency}:0.01`,
+        // purse timeout
+        "1h",
+        // kyc timeout
+        "1h",
+        // history expiration
+        "1year",
+        // free purses per account
+        "5",
+        "upload",
+      ],
+    );
   }
 
   async revokeDenomination(denomPubHash: string) {
diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index ebcee205..a1073dc3 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -1149,7 +1149,7 @@ testCli
       tVerify.start();
       const attestRes = AgeRestriction.commitmentVerify(
         commitProof.commitment,
-        attest,
+        encodeCrock(attest),
         18,
       );
       tVerify.stop();
@@ -1157,9 +1157,12 @@ testCli
         throw Error();
       }
 
-      const salt = encodeCrock(getRandomBytes(32));
+      const salt = getRandomBytes(32);
       tDerive.start();
-      const deriv = await AgeRestriction.commitmentDerive(commitProof, salt);
+      const deriv = await AgeRestriction.commitmentDerive(
+        commitProof,
+        salt,
+      );
       tDerive.stop();
 
       tCompare.start();
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
similarity index 55%
copy from packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts
copy to packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
index c38b8b38..5c716dc5 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
@@ -26,47 +26,54 @@ import {
 } from "../harness/helpers.js";
 
 /**
- * Run test for a payment for a "free" order with
- * an amount of zero.
+ * Run test for basic, bank-integrated withdrawal and payment.
  */
-export async function runPaymentZeroTest(t: GlobalTestState) {
+export async function runPeerToPeerTest(t: GlobalTestState) {
   // Set up test environment
 
-  const {
-    wallet,
-    bank,
-    exchange,
-    merchant,
-  } = await createSimpleTestkudosEnvironment(t);
-
-  // First, make a "free" payment when we don't even have
-  // any money in the
+  const { wallet, bank, exchange, merchant } =
+    await createSimpleTestkudosEnvironment(t);
 
   // Withdraw digital cash into the wallet.
+
   await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
 
   await wallet.runUntilDone();
 
-  await makeTestPayment(t, {
-    wallet,
-    merchant,
-    order: {
-      summary: "I am free!",
-      amount: "TESTKUDOS:0",
-      fulfillment_url: "taler://fulfillment-success/thx",
+  const resp = await wallet.client.call(
+    WalletApiOperation.InitiatePeerPushPayment,
+    {
+      amount: "TESTKUDOS:5",
+      partialContractTerms: {
+        summary: "Hello World",
+      },
     },
-  });
+  );
 
-  await wallet.runUntilDone();
+  console.log(resp);
 
-  const transactions = await wallet.client.call(
-    WalletApiOperation.GetTransactions,
-    {},
+  const checkResp = await wallet.client.call(
+    WalletApiOperation.CheckPeerPushPayment,
+    {
+      contractPriv: resp.contractPriv,
+      exchangeBaseUrl: resp.exchangeBaseUrl,
+      pursePub: resp.pursePub,
+    },
   );
 
-  for (const tr of transactions.transactions) {
-    t.assertDeepEqual(tr.pending, false);
-  }
+  console.log(checkResp);
+
+  const acceptResp = await wallet.client.call(
+    WalletApiOperation.AcceptPeerPushPayment,
+    {
+      exchangeBaseUrl: resp.exchangeBaseUrl,
+      pursePub: resp.pursePub,
+    },
+  );
+
+  console.log(acceptResp);
+
+  await wallet.runUntilDone();
 }
 
-runPaymentZeroTest.suites = ["wallet"];
+runPeerToPeerTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts 
b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
index e8aef513..cafcce79 100644
--- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
@@ -73,6 +73,7 @@ import { runPaymentDemoTest } from "./test-payment-on-demo";
 import { runPaymentTransientTest } from "./test-payment-transient";
 import { runPaymentZeroTest } from "./test-payment-zero.js";
 import { runPaywallFlowTest } from "./test-paywall-flow";
+import { runPeerToPeerTest } from "./test-peer-to-peer.js";
 import { runRefundTest } from "./test-refund";
 import { runRefundAutoTest } from "./test-refund-auto";
 import { runRefundGoneTest } from "./test-refund-gone";
@@ -153,6 +154,7 @@ const allTests: TestMainFunction[] = [
   runPaymentZeroTest,
   runPayPaidTest,
   runPaywallFlowTest,
+  runPeerToPeerTest,
   runRefundAutoTest,
   runRefundGoneTest,
   runRefundIncrementalTest,
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts 
b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
index 7c6b00bc..c177a51d 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -24,33 +24,47 @@
  * Imports.
  */
 
-// FIXME: Crypto should not use DB Types!
 import {
+  AgeCommitmentProof,
+  AgeRestriction,
   AmountJson,
   Amounts,
+  AmountString,
+  BlindedDenominationSignature,
+  bufferForUint32,
   buildSigPS,
+  bytesToString,
   CoinDepositPermission,
   CoinEnvelope,
-  createEddsaKeyPair,
   createHashContext,
   decodeCrock,
+  decryptContractForMerge,
   DenomKeyType,
   DepositInfo,
+  ecdheGetPublic,
   eddsaGetPublic,
+  EddsaPublicKeyString,
   eddsaSign,
   eddsaVerify,
   encodeCrock,
+  encryptContractForMerge,
   ExchangeProtocolVersion,
+  getRandomBytes,
   hash,
+  HashCodeString,
   hashCoinEv,
   hashCoinEvInner,
+  hashCoinPub,
   hashDenomPub,
   hashTruncate32,
+  kdf,
+  kdfKw,
   keyExchangeEcdheEddsa,
   Logger,
   MakeSyncSignatureRequest,
   PlanchetCreationRequest,
-  WithdrawalPlanchet,
+  PlanchetUnblindInfo,
+  PurseDeposit,
   RecoupRefreshRequest,
   RecoupRequest,
   RefreshPlanchetInfo,
@@ -59,30 +73,28 @@ import {
   rsaVerify,
   setupTipPlanchet,
   stringToBytes,
+  TalerProtocolTimestamp,
   TalerSignaturePurpose,
-  BlindedDenominationSignature,
   UnblindedSignature,
-  PlanchetUnblindInfo,
-  TalerProtocolTimestamp,
-  kdfKw,
-  bufferForUint32,
-  kdf,
-  ecdheGetPublic,
-  getRandomBytes,
-  AgeCommitmentProof,
-  AgeRestriction,
-  hashCoinPub,
-  HashCodeString,
+  WithdrawalPlanchet,
 } from "@gnu-taler/taler-util";
 import bigint from "big-integer";
-import { DenominationRecord, TipCoinSource, WireFee } from "../db.js";
+// FIXME: Crypto should not use DB Types!
+import { DenominationRecord, WireFee } from "../db.js";
 import {
   CreateRecoupRefreshReqRequest,
   CreateRecoupReqRequest,
+  DecryptContractRequest,
+  DecryptContractResponse,
   DerivedRefreshSession,
   DerivedTipPlanchet,
   DeriveRefreshSessionRequest,
   DeriveTipRequest,
+  EncryptContractRequest,
+  EncryptContractResponse,
+  EncryptedContract,
+  SignPurseMergeRequest,
+  SignPurseMergeResponse,
   SignTrackTransactionRequest,
 } from "./cryptoTypes.js";
 
@@ -177,6 +189,22 @@ export interface TalerCryptoInterface {
   setupRefreshTransferPub(
     req: SetupRefreshTransferPubRequest,
   ): Promise<TransferPubResponse>;
+
+  signPurseCreation(req: SignPurseCreationRequest): 
Promise<EddsaSigningResult>;
+
+  signPurseDeposits(
+    req: SignPurseDepositsRequest,
+  ): Promise<SignPurseDepositsResponse>;
+
+  encryptContractForMerge(
+    req: EncryptContractRequest,
+  ): Promise<EncryptContractResponse>;
+
+  decryptContractForMerge(
+    req: DecryptContractRequest,
+  ): Promise<DecryptContractResponse>;
+
+  signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
 }
 
 /**
@@ -308,6 +336,31 @@ export const nullCrypto: TalerCryptoInterface = {
   ): Promise<TransferPubResponse> {
     throw new Error("Function not implemented.");
   },
+  signPurseCreation: function (
+    req: SignPurseCreationRequest,
+  ): Promise<EddsaSigningResult> {
+    throw new Error("Function not implemented.");
+  },
+  signPurseDeposits: function (
+    req: SignPurseDepositsRequest,
+  ): Promise<SignPurseDepositsResponse> {
+    throw new Error("Function not implemented.");
+  },
+  encryptContractForMerge: function (
+    req: EncryptContractRequest,
+  ): Promise<EncryptContractResponse> {
+    throw new Error("Function not implemented.");
+  },
+  decryptContractForMerge: function (
+    req: DecryptContractRequest,
+  ): Promise<DecryptContractResponse> {
+    throw new Error("Function not implemented.");
+  },
+  signPurseMerge: function (
+    req: SignPurseMergeRequest,
+  ): Promise<SignPurseMergeResponse> {
+    throw new Error("Function not implemented.");
+  },
 };
 
 export type WithArg<X> = X extends (req: infer T) => infer R
@@ -336,6 +389,31 @@ export interface SetupWithdrawalPlanchetRequest {
   coinNumber: number;
 }
 
+export interface SignPurseCreationRequest {
+  pursePriv: string;
+  purseExpiration: TalerProtocolTimestamp;
+  purseAmount: AmountString;
+  hContractTerms: HashCodeString;
+  mergePub: EddsaPublicKeyString;
+  minAge: number;
+}
+
+export interface SignPurseDepositsRequest {
+  pursePub: string;
+  exchangeBaseUrl: string;
+  coins: {
+    coinPub: string;
+    coinPriv: string;
+    contribution: AmountString;
+    denomPubHash: string;
+    denomSig: UnblindedSignature;
+  }[];
+}
+
+export interface SignPurseDepositsResponse {
+  deposits: PurseDeposit[];
+}
+
 export interface RsaVerificationRequest {
   hm: string;
   sig: string;
@@ -459,6 +537,9 @@ export interface TransferPubResponse {
   transferPriv: string;
 }
 
+/**
+ * JS-native implementation of the Taler crypto worker operations.
+ */
 export const nativeCryptoR: TalerCryptoInterfaceR = {
   async eddsaSign(
     tci: TalerCryptoInterfaceR,
@@ -917,9 +998,11 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
       maybeAgeCommitmentHash = ach;
       hAgeCommitment = decodeCrock(ach);
       if (depositInfo.requiredMinimumAge != null) {
-        minimumAgeSig = AgeRestriction.commitmentAttest(
-          depositInfo.ageCommitmentProof,
-          depositInfo.requiredMinimumAge,
+        minimumAgeSig = encodeCrock(
+          AgeRestriction.commitmentAttest(
+            depositInfo.ageCommitmentProof,
+            depositInfo.requiredMinimumAge,
+          ),
         );
       }
     } else {
@@ -1051,7 +1134,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
           if (req.meltCoinAgeCommitmentProof) {
             newAc = await AgeRestriction.commitmentDerive(
               req.meltCoinAgeCommitmentProof,
-              transferSecretRes.h,
+              decodeCrock(transferSecretRes.h),
             );
             newAch = AgeRestriction.hashCommitment(newAc.commitment);
           }
@@ -1212,6 +1295,135 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
       transferPub: (await tci.ecdheGetPublic(tci, { priv: transferPriv })).pub,
     };
   },
+  async signPurseCreation(
+    tci: TalerCryptoInterfaceR,
+    req: SignPurseCreationRequest,
+  ): Promise<EddsaSigningResult> {
+    const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
+      .put(timestampRoundedToBuffer(req.purseExpiration))
+      .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+      .put(decodeCrock(req.hContractTerms))
+      .put(decodeCrock(req.mergePub))
+      .put(bufferForUint32(req.minAge))
+      .build();
+    return await tci.eddsaSign(tci, {
+      msg: encodeCrock(sigBlob),
+      priv: req.pursePriv,
+    });
+  },
+  async signPurseDeposits(
+    tci: TalerCryptoInterfaceR,
+    req: SignPurseDepositsRequest,
+  ): Promise<SignPurseDepositsResponse> {
+    const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
+    const deposits: PurseDeposit[] = [];
+    for (const c of req.coins) {
+      const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
+        .put(amountToBuffer(Amounts.parseOrThrow(c.contribution)))
+        .put(decodeCrock(c.denomPubHash))
+        // FIXME: use h_age_commitment here
+        .put(new Uint8Array(32))
+        .put(decodeCrock(req.pursePub))
+        .put(hExchangeBaseUrl)
+        .build();
+      const sigResp = await tci.eddsaSign(tci, {
+        msg: encodeCrock(sigBlob),
+        priv: c.coinPriv,
+      });
+      deposits.push({
+        amount: c.contribution,
+        coin_pub: c.coinPub,
+        coin_sig: sigResp.sig,
+        denom_pub_hash: c.denomPubHash,
+        ub_sig: c.denomSig,
+        h_age_commitment: undefined,
+      });
+    }
+    return {
+      deposits,
+    };
+  },
+  async encryptContractForMerge(
+    tci: TalerCryptoInterfaceR,
+    req: EncryptContractRequest,
+  ): Promise<EncryptContractResponse> {
+    const contractKeyPair = await this.createEddsaKeypair(tci, {});
+    const enc = await encryptContractForMerge(
+      decodeCrock(req.pursePub),
+      decodeCrock(contractKeyPair.priv),
+      decodeCrock(req.mergePriv),
+      req.contractTerms,
+    );
+    const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
+      .put(hash(enc))
+      .put(decodeCrock(contractKeyPair.pub))
+      .build();
+    const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
+    return {
+      econtract: {
+        contract_pub: contractKeyPair.pub,
+        econtract: encodeCrock(enc),
+        econtract_sig: encodeCrock(sig),
+      },
+      contractPriv: contractKeyPair.priv,
+    };
+  },
+  async decryptContractForMerge(
+    tci: TalerCryptoInterfaceR,
+    req: DecryptContractRequest,
+  ): Promise<DecryptContractResponse> {
+    const res = await decryptContractForMerge(
+      decodeCrock(req.ciphertext),
+      decodeCrock(req.pursePub),
+      decodeCrock(req.contractPriv),
+    );
+    return {
+      contractTerms: res.contractTerms,
+      mergePriv: encodeCrock(res.mergePriv),
+    };
+  },
+  async signPurseMerge(
+    tci: TalerCryptoInterfaceR,
+    req: SignPurseMergeRequest,
+  ): Promise<SignPurseMergeResponse> {
+    const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
+      .put(timestampRoundedToBuffer(req.mergeTimestamp))
+      .put(decodeCrock(req.pursePub))
+      .put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
+      .build();
+    const mergeSigResp = await tci.eddsaSign(tci, {
+      msg: encodeCrock(mergeSigBlob),
+      priv: req.mergePriv,
+    });
+
+    const reserveSigBlob = buildSigPS(
+      TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
+    )
+      .put(timestampRoundedToBuffer(req.purseExpiration))
+      .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+      .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
+      .put(decodeCrock(req.contractTermsHash))
+      .put(decodeCrock(req.pursePub))
+      .put(timestampRoundedToBuffer(req.mergeTimestamp))
+      // FIXME: put in min_age
+      .put(bufferForUint32(0))
+      .put(bufferForUint32(req.flags))
+      .build();
+
+    logger.info(
+      `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
+    );
+
+    const reserveSigResp = await tci.eddsaSign(tci, {
+      msg: encodeCrock(reserveSigBlob),
+      priv: req.reservePriv,
+    });
+
+    return {
+      mergeSig: mergeSigResp.sig,
+      accountSig: reserveSigResp.sig,
+    };
+  },
 };
 
 function amountToBuffer(amount: AmountJson): Uint8Array {
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts 
b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
index fe5dbcec..6f4a5fa9 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -30,11 +30,16 @@
 import {
   AgeCommitmentProof,
   AmountJson,
+  AmountString,
   CoinEnvelope,
   DenominationPubKey,
+  EddsaPublicKeyString,
+  EddsaSignatureString,
   ExchangeProtocolVersion,
   RefreshPlanchetInfo,
+  TalerProtocolTimestamp,
   UnblindedSignature,
+  WalletAccountMergeFlags,
 } from "@gnu-taler/taler-util";
 
 export interface RefreshNewDenomInfo {
@@ -149,3 +154,79 @@ export interface CreateRecoupRefreshReqRequest {
   denomPubHash: string;
   denomSig: UnblindedSignature;
 }
+
+export interface EncryptedContract {
+  /**
+   * Encrypted contract.
+   */
+  econtract: string;
+
+  /**
+   * Signature over the (encrypted) contract.
+   */
+  econtract_sig: EddsaSignatureString;
+
+  /**
+   * Ephemeral public key for the DH operation to decrypt the encrypted 
contract.
+   */
+  contract_pub: EddsaPublicKeyString;
+}
+
+export interface EncryptContractRequest {
+  contractTerms: any;
+
+  pursePub: string;
+  pursePriv: string;
+
+  mergePriv: string;
+}
+
+export interface EncryptContractResponse {
+  econtract: EncryptedContract;
+
+  contractPriv: string;
+}
+
+export interface DecryptContractRequest {
+  ciphertext: string;
+  pursePub: string;
+  contractPriv: string;
+}
+
+export interface DecryptContractResponse {
+  contractTerms: any;
+  mergePriv: string;
+}
+
+export interface SignPurseMergeRequest {
+  mergeTimestamp: TalerProtocolTimestamp;
+
+  pursePub: string;
+
+  reservePayto: string;
+
+  reservePriv: string;
+
+  mergePriv: string;
+
+  purseExpiration: TalerProtocolTimestamp;
+
+  purseAmount: AmountString;
+  purseFee: AmountString;
+
+  contractTermsHash: string;
+
+  /**
+   * Flags.
+   */
+  flags: WalletAccountMergeFlags;
+}
+
+export interface SignPurseMergeResponse {
+  /**
+   * Signature made by the purse's merge private key.
+   */
+  mergeSig: string;
+  
+  accountSig: string;
+}
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index eefa4311..e4f4ba25 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -42,6 +42,7 @@ import {
   TalerProtocolDuration,
   AgeCommitmentProof,
   PayCoinSelection,
+  PeerContractTerms,
 } from "@gnu-taler/taler-util";
 import { RetryInfo } from "./util/retries.js";
 import { Event, IDBDatabase } from "@gnu-taler/idb-bridge";
@@ -561,6 +562,12 @@ export interface ExchangeRecord {
    * Retry status for fetching updated information about the exchange.
    */
   retryInfo?: RetryInfo;
+
+  /**
+   * Public key of the reserve that we're currently using for
+   * receiving P2P payments.
+   */
+  currentMergeReservePub?: string;
 }
 
 /**
@@ -1309,9 +1316,9 @@ export const WALLET_BACKUP_STATE_KEY = 
"walletBackupState";
  */
 export type ConfigRecord =
   | {
-    key: typeof WALLET_BACKUP_STATE_KEY;
-    value: WalletBackupConfState;
-  }
+      key: typeof WALLET_BACKUP_STATE_KEY;
+      value: WalletBackupConfState;
+    }
   | { key: "currencyDefaultsApplied"; value: boolean };
 
 export interface WalletBackupConfState {
@@ -1497,17 +1504,17 @@ export enum BackupProviderStateTag {
 
 export type BackupProviderState =
   | {
-    tag: BackupProviderStateTag.Provisional;
-  }
+      tag: BackupProviderStateTag.Provisional;
+    }
   | {
-    tag: BackupProviderStateTag.Ready;
-    nextBackupTimestamp: TalerProtocolTimestamp;
-  }
+      tag: BackupProviderStateTag.Ready;
+      nextBackupTimestamp: TalerProtocolTimestamp;
+    }
   | {
-    tag: BackupProviderStateTag.Retrying;
-    retryInfo: RetryInfo;
-    lastError?: TalerErrorDetail;
-  };
+      tag: BackupProviderStateTag.Retrying;
+      retryInfo: RetryInfo;
+      lastError?: TalerErrorDetail;
+    };
 
 export interface BackupProviderTerms {
   supportedProtocolVersion: string;
@@ -1671,6 +1678,73 @@ export interface BalancePerCurrencyRecord {
   pendingOutgoing: AmountString;
 }
 
+/**
+ * Record for a push P2P payment that this wallet initiated.
+ */
+export interface PeerPushPaymentInitiationRecord {
+  /**
+   * What exchange are funds coming from?
+   */
+  exchangeBaseUrl: string;
+
+  amount: AmountString;
+
+  /**
+   * Purse public key.  Used as the primary key to look
+   * up this record.
+   */
+  pursePub: string;
+
+  /**
+   * Purse private key.
+   */
+  pursePriv: string;
+
+  /**
+   * Public key of the merge capability of the purse.
+   */
+  mergePub: string;
+
+  /**
+   * Private key of the merge capability of the purse.
+   */
+  mergePriv: string;
+
+  contractPriv: string;
+
+  contractPub: string;
+
+  purseExpiration: TalerProtocolTimestamp;
+
+  /**
+   * Did we successfully create the purse with the exchange?
+   */
+  purseCreated: boolean;
+
+  timestampCreated: TalerProtocolTimestamp;
+}
+
+/**
+ * Record for a push P2P payment that this wallet was offered.
+ *
+ * Primary key: (exchangeBaseUrl, pursePub)
+ */
+export interface PeerPushPaymentIncomingRecord {
+  exchangeBaseUrl: string;
+
+  pursePub: string;
+
+  mergePriv: string;
+
+  contractPriv: string;
+
+  timestampAccepted: TalerProtocolTimestamp;
+
+  contractTerms: PeerContractTerms;
+
+  // FIXME: add status etc.
+}
+
 export const WalletStoresV1 = {
   coins: describeStore(
     describeContents<CoinRecord>("coins", {
@@ -1847,6 +1921,12 @@ export const WalletStoresV1 = {
     }),
     {},
   ),
+  peerPushPaymentIncoming: describeStore(
+    describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", 
{
+      keyPath: ["exchangeBaseUrl", "pursePub"],
+    }),
+    {},
+  ),
 };
 
 export interface MetaConfigRecord {
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts 
b/packages/taler-wallet-core/src/operations/backup/import.ts
index 3a912150..e4eaf891 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -16,22 +16,46 @@
 
 import {
   AmountJson,
-  Amounts, BackupCoinSourceType, BackupDenomSel, BackupProposalStatus,
-  BackupPurchase, BackupRefreshReason, BackupRefundState, 
codecForContractTerms,
-  DenomKeyType, j2s, Logger, PayCoinSelection, RefreshReason, 
TalerProtocolTimestamp,
-  WalletBackupContentV1
+  Amounts,
+  BackupCoinSourceType,
+  BackupDenomSel,
+  BackupProposalStatus,
+  BackupPurchase,
+  BackupRefreshReason,
+  BackupRefundState,
+  codecForContractTerms,
+  DenomKeyType,
+  j2s,
+  Logger,
+  PayCoinSelection,
+  RefreshReason,
+  TalerProtocolTimestamp,
+  WalletBackupContentV1,
 } from "@gnu-taler/taler-util";
 import {
-  AbortStatus, CoinSource,
+  AbortStatus,
+  CoinSource,
   CoinSourceType,
-  CoinStatus, DenominationVerificationStatus, DenomSelectionState, 
OperationStatus, ProposalDownload,
-  ProposalStatus, RefreshCoinStatus, RefreshSessionRecord, RefundState, 
ReserveBankInfo,
-  ReserveRecordStatus, WalletContractData, WalletRefundItem, WalletStoresV1, 
WireInfo
+  CoinStatus,
+  DenominationVerificationStatus,
+  DenomSelectionState,
+  OperationStatus,
+  ProposalDownload,
+  ProposalStatus,
+  RefreshCoinStatus,
+  RefreshSessionRecord,
+  RefundState,
+  ReserveBankInfo,
+  ReserveRecordStatus,
+  WalletContractData,
+  WalletRefundItem,
+  WalletStoresV1,
+  WireInfo,
 } from "../../db.js";
 import { InternalWalletState } from "../../internal-wallet-state.js";
 import {
   checkDbInvariant,
-  checkLogicInvariant
+  checkLogicInvariant,
 } from "../../util/invariants.js";
 import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
 import { RetryInfo } from "../../util/retries.js";
@@ -313,14 +337,12 @@ export async function importBackup(
         }
 
         for (const backupDenomination of backupExchangeDetails.denominations) {
-          if (
-            backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa
-          ) {
+          if (backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa) {
             throw Error("unsupported cipher");
           }
           const denomPubHash =
             cryptoComp.rsaDenomPubToHash[
-            backupDenomination.denom_pub.rsa_public_key
+              backupDenomination.denom_pub.rsa_public_key
             ];
           checkLogicInvariant(!!denomPubHash);
           const existingDenom = await tx.denominations.get([
@@ -535,7 +557,7 @@ export async function importBackup(
             const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
             const contractTermsHash =
               cryptoComp.proposalIdToContractTermsHash[
-              backupProposal.proposal_id
+                backupProposal.proposal_id
               ];
             let maxWireFee: AmountJson;
             if (parsedContractTerms.max_wire_fee) {
@@ -679,7 +701,7 @@ export async function importBackup(
           const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
           const contractTermsHash =
             cryptoComp.proposalIdToContractTermsHash[
-            backupPurchase.proposal_id
+              backupPurchase.proposal_id
             ];
           let maxWireFee: AmountJson;
           if (parsedContractTerms.max_wire_fee) {
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index b6bae751..55b8f513 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -35,6 +35,7 @@ import {
   ConfirmPayResult,
   ConfirmPayResultType,
   ContractTerms,
+  ContractTermsUtil,
   Duration,
   durationMax,
   durationMin,
@@ -87,7 +88,6 @@ import {
   selectForcedPayCoins,
   selectPayCoins,
 } from "../util/coinSelection.js";
-import { ContractTermsUtil } from "../util/contractTerms.js";
 import {
   getHttpResponseErrorDetails,
   readSuccessResponseJsonOrErrorCode,
diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts 
b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
new file mode 100644
index 00000000..658cbe4f
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
@@ -0,0 +1,464 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+  AbsoluteTime,
+  AcceptPeerPushPaymentRequest,
+  AmountJson,
+  Amounts,
+  AmountString,
+  buildCodecForObject,
+  CheckPeerPushPaymentRequest,
+  CheckPeerPushPaymentResponse,
+  Codec,
+  codecForAmountString,
+  codecForAny,
+  codecForExchangeGetContractResponse,
+  ContractTermsUtil,
+  decodeCrock,
+  Duration,
+  eddsaGetPublic,
+  encodeCrock,
+  ExchangePurseMergeRequest,
+  InitiatePeerPushPaymentRequest,
+  InitiatePeerPushPaymentResponse,
+  j2s,
+  Logger,
+  strcmp,
+  TalerProtocolTimestamp,
+  UnblindedSignature,
+  WalletAccountMergeFlags,
+} from "@gnu-taler/taler-util";
+import { url } from "inspector";
+import {
+  CoinStatus,
+  OperationStatus,
+  ReserveRecord,
+  ReserveRecordStatus,
+} from "../db.js";
+import {
+  checkSuccessResponseOrThrow,
+  readSuccessResponseJsonOrThrow,
+  throwUnexpectedRequestError,
+} from "../util/http.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant } from "../util/invariants.js";
+
+const logger = new Logger("operations/peer-to-peer.ts");
+
+export interface PeerCoinSelection {
+  exchangeBaseUrl: string;
+
+  /**
+   * Info of Coins that were selected.
+   */
+  coins: {
+    coinPub: string;
+    coinPriv: string;
+    contribution: AmountString;
+    denomPubHash: string;
+    denomSig: UnblindedSignature;
+  }[];
+
+  /**
+   * How much of the deposit fees is the customer paying?
+   */
+  depositFees: AmountJson;
+}
+
+interface CoinInfo {
+  /**
+   * Public key of the coin.
+   */
+  coinPub: string;
+
+  coinPriv: string;
+
+  /**
+   * Deposit fee for the coin.
+   */
+  feeDeposit: AmountJson;
+
+  value: AmountJson;
+
+  denomPubHash: string;
+
+  denomSig: UnblindedSignature;
+}
+
+export async function initiatePeerToPeerPush(
+  ws: InternalWalletState,
+  req: InitiatePeerPushPaymentRequest,
+): Promise<InitiatePeerPushPaymentResponse> {
+  const instructedAmount = Amounts.parseOrThrow(req.amount);
+  const coinSelRes: PeerCoinSelection | undefined = await ws.db
+    .mktx((x) => ({
+      exchanges: x.exchanges,
+      coins: x.coins,
+      denominations: x.denominations,
+    }))
+    .runReadOnly(async (tx) => {
+      const exchanges = await tx.exchanges.iter().toArray();
+      for (const exch of exchanges) {
+        if (exch.detailsPointer?.currency !== instructedAmount.currency) {
+          continue;
+        }
+        const coins = (
+          await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
+        ).filter((x) => x.status === CoinStatus.Fresh);
+        const coinInfos: CoinInfo[] = [];
+        for (const coin of coins) {
+          const denom = await ws.getDenomInfo(
+            ws,
+            tx,
+            coin.exchangeBaseUrl,
+            coin.denomPubHash,
+          );
+          if (!denom) {
+            throw Error("denom not found");
+          }
+          coinInfos.push({
+            coinPub: coin.coinPub,
+            feeDeposit: denom.feeDeposit,
+            value: denom.value,
+            denomPubHash: denom.denomPubHash,
+            coinPriv: coin.coinPriv,
+            denomSig: coin.denomSig,
+          });
+        }
+        if (coinInfos.length === 0) {
+          continue;
+        }
+        coinInfos.sort(
+          (o1, o2) =>
+            -Amounts.cmp(o1.value, o2.value) ||
+            strcmp(o1.denomPubHash, o2.denomPubHash),
+        );
+        let amountAcc = Amounts.getZero(instructedAmount.currency);
+        let depositFeesAcc = Amounts.getZero(instructedAmount.currency);
+        const resCoins: {
+          coinPub: string;
+          coinPriv: string;
+          contribution: AmountString;
+          denomPubHash: string;
+          denomSig: UnblindedSignature;
+        }[] = [];
+        for (const coin of coinInfos) {
+          if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
+            const res: PeerCoinSelection = {
+              exchangeBaseUrl: exch.baseUrl,
+              coins: resCoins,
+              depositFees: depositFeesAcc,
+            };
+            return res;
+          }
+          const gap = Amounts.add(
+            coin.feeDeposit,
+            Amounts.sub(instructedAmount, amountAcc).amount,
+          ).amount;
+          const contrib = Amounts.min(gap, coin.value);
+          amountAcc = Amounts.add(
+            amountAcc,
+            Amounts.sub(contrib, coin.feeDeposit).amount,
+          ).amount;
+          depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
+          resCoins.push({
+            coinPriv: coin.coinPriv,
+            coinPub: coin.coinPub,
+            contribution: Amounts.stringify(contrib),
+            denomPubHash: coin.denomPubHash,
+            denomSig: coin.denomSig,
+          });
+        }
+        continue;
+      }
+      return undefined;
+    });
+  logger.info(`selected p2p coins: ${j2s(coinSelRes)}`);
+
+  if (!coinSelRes) {
+    throw Error("insufficient balance");
+  }
+
+  const pursePair = await ws.cryptoApi.createEddsaKeypair({});
+  const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp(
+    AbsoluteTime.addDuration(
+      AbsoluteTime.now(),
+      Duration.fromSpec({ days: 2 }),
+    ),
+  );
+
+  const contractTerms = {
+    ...req.partialContractTerms,
+    purse_expiration: purseExpiration,
+    amount: req.amount,
+  };
+
+  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+  const purseSigResp = await ws.cryptoApi.signPurseCreation({
+    hContractTerms,
+    mergePub: mergePair.pub,
+    minAge: 0,
+    purseAmount: Amounts.stringify(instructedAmount),
+    purseExpiration,
+    pursePriv: pursePair.priv,
+  });
+
+  const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
+    exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
+    pursePub: pursePair.pub,
+    coins: coinSelRes.coins,
+  });
+
+  const createPurseUrl = new URL(
+    `purses/${pursePair.pub}/create`,
+    coinSelRes.exchangeBaseUrl,
+  );
+
+  const econtractResp = await ws.cryptoApi.encryptContractForMerge({
+    contractTerms,
+    mergePriv: mergePair.priv,
+    pursePriv: pursePair.priv,
+    pursePub: pursePair.pub,
+  });
+
+  const httpResp = await ws.http.postJson(createPurseUrl.href, {
+    amount: Amounts.stringify(instructedAmount),
+    merge_pub: mergePair.pub,
+    purse_sig: purseSigResp.sig,
+    h_contract_terms: hContractTerms,
+    purse_expiration: purseExpiration,
+    deposits: depositSigsResp.deposits,
+    min_age: 0,
+    econtract: econtractResp.econtract,
+  });
+
+  const resp = await httpResp.json();
+
+  logger.info(`resp: ${j2s(resp)}`);
+
+  if (httpResp.status !== 200) {
+    throw Error("got error response from exchange");
+  }
+
+  return {
+    contractPriv: econtractResp.contractPriv,
+    mergePriv: mergePair.priv,
+    pursePub: pursePair.pub,
+    exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
+  };
+}
+
+interface ExchangePurseStatus {
+  balance: AmountString;
+}
+
+export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
+  buildCodecForObject<ExchangePurseStatus>()
+    .property("balance", codecForAmountString())
+    .build("ExchangePurseStatus");
+
+export async function checkPeerPushPayment(
+  ws: InternalWalletState,
+  req: CheckPeerPushPaymentRequest,
+): Promise<CheckPeerPushPaymentResponse> {
+  const getPurseUrl = new URL(
+    `purses/${req.pursePub}/deposit`,
+    req.exchangeBaseUrl,
+  );
+
+  const contractPub = encodeCrock(
+    eddsaGetPublic(decodeCrock(req.contractPriv)),
+  );
+
+  const purseHttpResp = await ws.http.get(getPurseUrl.href);
+
+  const purseStatus = await readSuccessResponseJsonOrThrow(
+    purseHttpResp,
+    codecForExchangePurseStatus(),
+  );
+
+  const getContractUrl = new URL(
+    `contracts/${contractPub}`,
+    req.exchangeBaseUrl,
+  );
+
+  const contractHttpResp = await ws.http.get(getContractUrl.href);
+
+  const contractResp = await readSuccessResponseJsonOrThrow(
+    contractHttpResp,
+    codecForExchangeGetContractResponse(),
+  );
+
+  const dec = await ws.cryptoApi.decryptContractForMerge({
+    ciphertext: contractResp.econtract,
+    contractPriv: req.contractPriv,
+    pursePub: req.pursePub,
+  });
+
+  await ws.db
+    .mktx((x) => ({
+      peerPushPaymentIncoming: x.peerPushPaymentIncoming,
+    }))
+    .runReadWrite(async (tx) => {
+      await tx.peerPushPaymentIncoming.add({
+        contractPriv: req.contractPriv,
+        exchangeBaseUrl: req.exchangeBaseUrl,
+        mergePriv: dec.mergePriv,
+        pursePub: req.pursePub,
+        timestampAccepted: TalerProtocolTimestamp.now(),
+        contractTerms: dec.contractTerms,
+      });
+    });
+
+  return {
+    amount: purseStatus.balance,
+    contractTerms: dec.contractTerms,
+  };
+}
+
+export function talerPaytoFromExchangeReserve(
+  exchangeBaseUrl: string,
+  reservePub: string,
+): string {
+  const url = new URL(exchangeBaseUrl);
+  let proto: string;
+  if (url.protocol === "http:") {
+    proto = "taler+http";
+  } else if (url.protocol === "https:") {
+    proto = "taler";
+  } else {
+    throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
+  }
+
+  let path = url.pathname;
+  if (!path.endsWith("/")) {
+    path = path + "/";
+  }
+
+  return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
+}
+
+export async function acceptPeerPushPayment(
+  ws: InternalWalletState,
+  req: AcceptPeerPushPaymentRequest,
+) {
+  const peerInc = await ws.db
+    .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
+    .runReadOnly(async (tx) => {
+      return tx.peerPushPaymentIncoming.get([
+        req.exchangeBaseUrl,
+        req.pursePub,
+      ]);
+    });
+
+  if (!peerInc) {
+    throw Error("can't accept unknown incoming p2p push payment");
+  }
+
+  const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
+
+  // We have to create the key pair outside of the transaction,
+  // due to the async crypto API.
+  const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
+
+  const reserve: ReserveRecord | undefined = await ws.db
+    .mktx((x) => ({
+      exchanges: x.exchanges,
+      reserves: x.reserves,
+    }))
+    .runReadWrite(async (tx) => {
+      const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+      checkDbInvariant(!!ex);
+      if (ex.currentMergeReservePub) {
+        return await tx.reserves.get(ex.currentMergeReservePub);
+      }
+      const rec: ReserveRecord = {
+        exchangeBaseUrl: req.exchangeBaseUrl,
+        // FIXME: field will be removed in the future, folded into 
withdrawal/p2p record.
+        reserveStatus: ReserveRecordStatus.Dormant,
+        timestampCreated: TalerProtocolTimestamp.now(),
+        instructedAmount: Amounts.getZero(amount.currency),
+        currency: amount.currency,
+        reservePub: newReservePair.pub,
+        reservePriv: newReservePair.priv,
+        timestampBankConfirmed: undefined,
+        timestampReserveInfoPosted: undefined,
+        // FIXME!
+        initialDenomSel: undefined as any,
+        // FIXME!
+        initialWithdrawalGroupId: "",
+        initialWithdrawalStarted: false,
+        lastError: undefined,
+        operationStatus: OperationStatus.Pending,
+        retryInfo: undefined,
+        bankInfo: undefined,
+        restrictAge: undefined,
+        senderWire: undefined,
+      };
+      await tx.reserves.put(rec);
+      return rec;
+    });
+
+  if (!reserve) {
+    throw Error("can't create reserve");
+  }
+
+  const mergeTimestamp = TalerProtocolTimestamp.now();
+
+  const reservePayto = talerPaytoFromExchangeReserve(
+    reserve.exchangeBaseUrl,
+    reserve.reservePub,
+  );
+
+  const sigRes = await ws.cryptoApi.signPurseMerge({
+    contractTermsHash: ContractTermsUtil.hashContractTerms(
+      peerInc.contractTerms,
+    ),
+    flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+    mergePriv: peerInc.mergePriv,
+    mergeTimestamp: mergeTimestamp,
+    purseAmount: Amounts.stringify(amount),
+    purseExpiration: peerInc.contractTerms.purse_expiration,
+    purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
+    pursePub: peerInc.pursePub,
+    reservePayto,
+    reservePriv: reserve.reservePriv,
+  });
+
+  const mergePurseUrl = new URL(
+    `purses/${req.pursePub}/merge`,
+    req.exchangeBaseUrl,
+  );
+
+  const mergeReq: ExchangePurseMergeRequest = {
+    payto_uri: reservePayto,
+    merge_timestamp: mergeTimestamp,
+    merge_sig: sigRes.mergeSig,
+    reserve_sig: sigRes.accountSig,
+  };
+
+  const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
+
+  const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, 
codecForAny());
+  logger.info(`merge result: ${j2s(res)}`);
+}
diff --git a/packages/taler-wallet-core/src/util/contractTerms.test.ts 
b/packages/taler-wallet-core/src/util/contractTerms.test.ts
deleted file mode 100644
index 74cae4ca..00000000
--- a/packages/taler-wallet-core/src/util/contractTerms.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import test from "ava";
-import { ContractTermsUtil } from "./contractTerms.js";
-
-test("contract terms canon hashing", (t) => {
-  const cReq = {
-    foo: 42,
-    bar: "hello",
-    $forgettable: {
-      foo: true,
-    },
-  };
-
-  const c1 = ContractTermsUtil.saltForgettable(cReq);
-  const c2 = ContractTermsUtil.saltForgettable(cReq);
-  t.assert(typeof cReq.$forgettable.foo === "boolean");
-  t.assert(typeof c1.$forgettable.foo === "string");
-  t.assert(c1.$forgettable.foo !== c2.$forgettable.foo);
-
-  const h1 = ContractTermsUtil.hashContractTerms(c1);
-
-  const c3 = ContractTermsUtil.scrub(JSON.parse(JSON.stringify(c1)));
-
-  t.assert(c3.foo === undefined);
-  t.assert(c3.bar === cReq.bar);
-
-  const h2 = ContractTermsUtil.hashContractTerms(c3);
-
-  t.deepEqual(h1, h2);
-});
-
-test("contract terms canon hashing (nested)", (t) => {
-  const cReq = {
-    foo: 42,
-    bar: {
-      prop1: "hello, world",
-      $forgettable: {
-        prop1: true,
-      },
-    },
-    $forgettable: {
-      bar: true,
-    },
-  };
-
-  const c1 = ContractTermsUtil.saltForgettable(cReq);
-
-  t.is(typeof c1.$forgettable.bar, "string");
-  t.is(typeof c1.bar.$forgettable.prop1, "string");
-
-  const forgetPath = (x: any, s: string) =>
-    ContractTermsUtil.forgetAll(x, (p) => p.join(".") === s);
-
-  // Forget bar first
-  const c2 = forgetPath(c1, "bar");
-
-  // Forget bar.prop1 first
-  const c3 = forgetPath(forgetPath(c1, "bar.prop1"), "bar");
-
-  // Forget everything
-  const c4 = ContractTermsUtil.scrub(c1);
-
-  const h1 = ContractTermsUtil.hashContractTerms(c1);
-  const h2 = ContractTermsUtil.hashContractTerms(c2);
-  const h3 = ContractTermsUtil.hashContractTerms(c3);
-  const h4 = ContractTermsUtil.hashContractTerms(c4);
-
-  t.is(h1, h2);
-  t.is(h1, h3);
-  t.is(h1, h4);
-
-  // Doesn't contain salt
-  t.false(ContractTermsUtil.validateForgettable(cReq));
-
-  t.true(ContractTermsUtil.validateForgettable(c1));
-  t.true(ContractTermsUtil.validateForgettable(c2));
-  t.true(ContractTermsUtil.validateForgettable(c3));
-  t.true(ContractTermsUtil.validateForgettable(c4));
-});
-
-test("contract terms reference vector", (t) => {
-  const j = {
-    k1: 1,
-    $forgettable: {
-      k1: "SALT",
-    },
-    k2: {
-      n1: true,
-      $forgettable: {
-        n1: "salt",
-      },
-    },
-    k3: {
-      n1: "string",
-    },
-  };
-
-  const h = ContractTermsUtil.hashContractTerms(j);
-
-  t.deepEqual(
-    h,
-    
"VDE8JPX0AEEE3EX1K8E11RYEWSZQKGGZCV6BWTE4ST1C8711P7H850Z7F2Q2HSSYETX87ERC2JNHWB7GTDWTDWMM716VKPSRBXD7SRR",
-  );
-});
diff --git a/packages/taler-wallet-core/src/util/contractTerms.ts 
b/packages/taler-wallet-core/src/util/contractTerms.ts
deleted file mode 100644
index c2f1ba07..00000000
--- a/packages/taler-wallet-core/src/util/contractTerms.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
- */
-
-import { canonicalJson, Logger } from "@gnu-taler/taler-util";
-import { kdf } from "@gnu-taler/taler-util";
-import {
-  decodeCrock,
-  encodeCrock,
-  getRandomBytes,
-  hash,
-  stringToBytes,
-} from "@gnu-taler/taler-util";
-
-const logger = new Logger("contractTerms.ts");
-
-export namespace ContractTermsUtil {
-  export type PathPredicate = (path: string[]) => boolean;
-
-  /**
-   * Scrub all forgettable members from an object.
-   */
-  export function scrub(anyJson: any): any {
-    return forgetAllImpl(anyJson, [], () => true);
-  }
-
-  /**
-   * Recursively forget all forgettable members of an object,
-   * where the path matches a predicate.
-   */
-  export function forgetAll(anyJson: any, pred: PathPredicate): any {
-    return forgetAllImpl(anyJson, [], pred);
-  }
-
-  function forgetAllImpl(
-    anyJson: any,
-    path: string[],
-    pred: PathPredicate,
-  ): any {
-    const dup = JSON.parse(JSON.stringify(anyJson));
-    if (Array.isArray(dup)) {
-      for (let i = 0; i < dup.length; i++) {
-        dup[i] = forgetAllImpl(dup[i], [...path, `${i}`], pred);
-      }
-    } else if (typeof dup === "object" && dup != null) {
-      if (typeof dup.$forgettable === "object") {
-        for (const x of Object.keys(dup.$forgettable)) {
-          if (!pred([...path, x])) {
-            continue;
-          }
-          if (!dup.$forgotten) {
-            dup.$forgotten = {};
-          }
-          if (!dup.$forgotten[x]) {
-            const membValCanon = stringToBytes(
-              canonicalJson(scrub(dup[x])) + "\0",
-            );
-            const membSalt = stringToBytes(dup.$forgettable[x] + "\0");
-            const h = kdf(64, membValCanon, membSalt, new Uint8Array([]));
-            dup.$forgotten[x] = encodeCrock(h);
-          }
-          delete dup[x];
-          delete dup.$forgettable[x];
-        }
-        if (Object.keys(dup.$forgettable).length === 0) {
-          delete dup.$forgettable;
-        }
-      }
-      for (const x of Object.keys(dup)) {
-        if (x.startsWith("$")) {
-          continue;
-        }
-        dup[x] = forgetAllImpl(dup[x], [...path, x], pred);
-      }
-    }
-    return dup;
-  }
-
-  /**
-   * Generate a salt for all members marked as forgettable,
-   * but which don't have an actual salt yet.
-   */
-  export function saltForgettable(anyJson: any): any {
-    const dup = JSON.parse(JSON.stringify(anyJson));
-    if (Array.isArray(dup)) {
-      for (let i = 0; i < dup.length; i++) {
-        dup[i] = saltForgettable(dup[i]);
-      }
-    } else if (typeof dup === "object" && dup !== null) {
-      if (typeof dup.$forgettable === "object") {
-        for (const k of Object.keys(dup.$forgettable)) {
-          if (dup.$forgettable[k] === true) {
-            dup.$forgettable[k] = encodeCrock(getRandomBytes(32));
-          }
-        }
-      }
-      for (const x of Object.keys(dup)) {
-        if (x.startsWith("$")) {
-          continue;
-        }
-        dup[x] = saltForgettable(dup[x]);
-      }
-    }
-    return dup;
-  }
-
-  const nameRegex = /^[0-9A-Za-z_]+$/;
-
-  /**
-   * Check that the given JSON object is well-formed with regards
-   * to forgettable fields and other restrictions for forgettable JSON.
-   */
-  export function validateForgettable(anyJson: any): boolean {
-    if (typeof anyJson === "string") {
-      return true;
-    }
-    if (typeof anyJson === "number") {
-      return (
-        Number.isInteger(anyJson) &&
-        anyJson >= Number.MIN_SAFE_INTEGER &&
-        anyJson <= Number.MAX_SAFE_INTEGER
-      );
-    }
-    if (typeof anyJson === "boolean") {
-      return true;
-    }
-    if (anyJson === null) {
-      return true;
-    }
-    if (Array.isArray(anyJson)) {
-      return anyJson.every((x) => validateForgettable(x));
-    }
-    if (typeof anyJson === "object") {
-      for (const k of Object.keys(anyJson)) {
-        if (k.match(nameRegex)) {
-          if (validateForgettable(anyJson[k])) {
-            continue;
-          } else {
-            return false;
-          }
-        }
-        if (k === "$forgettable") {
-          const fga = anyJson.$forgettable;
-          if (!fga || typeof fga !== "object") {
-            return false;
-          }
-          for (const fk of Object.keys(fga)) {
-            if (!fk.match(nameRegex)) {
-              return false;
-            }
-            if (!(fk in anyJson)) {
-              return false;
-            }
-            const fv = anyJson.$forgettable[fk];
-            if (typeof fv !== "string") {
-              return false;
-            }
-          }
-        } else if (k === "$forgotten") {
-          const fgo = anyJson.$forgotten;
-          if (!fgo || typeof fgo !== "object") {
-            return false;
-          }
-          for (const fk of Object.keys(fgo)) {
-            if (!fk.match(nameRegex)) {
-              return false;
-            }
-            // Check that the value has actually been forgotten.
-            if (fk in anyJson) {
-              return false;
-            }
-            const fv = anyJson.$forgotten[fk];
-            if (typeof fv !== "string") {
-              return false;
-            }
-            try {
-              const decFv = decodeCrock(fv);
-              if (decFv.length != 64) {
-                return false;
-              }
-            } catch (e) {
-              return false;
-            }
-            // Check that salt has been deleted after forgetting.
-            if (anyJson.$forgettable?.[k] !== undefined) {
-              return false;
-            }
-          }
-        } else {
-          return false;
-        }
-      }
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Check that no forgettable information has been forgotten.
-   *
-   * Must only be called on an object already validated with 
validateForgettable.
-   */
-  export function validateNothingForgotten(contractTerms: any): boolean {
-    throw Error("not implemented yet");
-  }
-
-  /**
-   * Hash a contract terms object.  Forgettable fields
-   * are scrubbed and JSON canonicalization is applied
-   * before hashing.
-   */
-  export function hashContractTerms(contractTerms: unknown): string {
-    const cleaned = scrub(contractTerms);
-    const canon = canonicalJson(cleaned) + "\0";
-    const bytes = stringToBytes(canon);
-    return encodeCrock(hash(bytes));
-  }
-}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts 
b/packages/taler-wallet-core/src/wallet-api-types.ts
index 9acfbf10..cc9e98f8 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -27,6 +27,7 @@ import {
   AcceptExchangeTosRequest,
   AcceptManualWithdrawalRequest,
   AcceptManualWithdrawalResult,
+  AcceptPeerPushPaymentRequest,
   AcceptTipRequest,
   AcceptWithdrawalResponse,
   AddExchangeRequest,
@@ -34,6 +35,8 @@ import {
   ApplyRefundResponse,
   BackupRecovery,
   BalancesResponse,
+  CheckPeerPushPaymentRequest,
+  CheckPeerPushPaymentResponse,
   CoinDumpJson,
   ConfirmPayRequest,
   ConfirmPayResult,
@@ -46,6 +49,8 @@ import {
   GetExchangeTosResult,
   GetWithdrawalDetailsForAmountRequest,
   GetWithdrawalDetailsForUriRequest,
+  InitiatePeerPushPaymentRequest,
+  InitiatePeerPushPaymentResponse,
   IntegrationTestArgs,
   ManualWithdrawalDetails,
   PreparePayRequest,
@@ -118,6 +123,9 @@ export enum WalletApiOperation {
   ExportBackupPlain = "exportBackupPlain",
   WithdrawFakebank = "withdrawFakebank",
   ExportDb = "exportDb",
+  InitiatePeerPushPayment = "initiatePeerPushPayment",
+  CheckPeerPushPayment = "checkPeerPushPayment",
+  AcceptPeerPushPayment = "acceptPeerPushPayment",
 }
 
 export type WalletOperations = {
@@ -277,6 +285,18 @@ export type WalletOperations = {
     request: {};
     response: any;
   };
+  [WalletApiOperation.InitiatePeerPushPayment]: {
+    request: InitiatePeerPushPaymentRequest;
+    response: InitiatePeerPushPaymentResponse;
+  };
+  [WalletApiOperation.CheckPeerPushPayment]: {
+    request: CheckPeerPushPaymentRequest;
+    response: CheckPeerPushPaymentResponse;
+  };
+  [WalletApiOperation.AcceptPeerPushPayment]: {
+    request: AcceptPeerPushPaymentRequest;
+    response: {};
+  };
 };
 
 export type RequestType<
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index c7b94138..b56e9402 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -32,11 +32,13 @@ import {
   codecForAcceptBankIntegratedWithdrawalRequest,
   codecForAcceptExchangeTosRequest,
   codecForAcceptManualWithdrawalRequet,
+  codecForAcceptPeerPushPaymentRequest,
   codecForAcceptTipRequest,
   codecForAddExchangeRequest,
   codecForAny,
   codecForApplyRefundFromPurchaseIdRequest,
   codecForApplyRefundRequest,
+  codecForCheckPeerPushPaymentRequest,
   codecForConfirmPayRequest,
   codecForCreateDepositGroupRequest,
   codecForDeleteTransactionRequest,
@@ -47,6 +49,7 @@ import {
   codecForGetWithdrawalDetailsForAmountRequest,
   codecForGetWithdrawalDetailsForUri,
   codecForImportDbRequest,
+  codecForInitiatePeerPushPaymentRequest,
   codecForIntegrationTestArgs,
   codecForListKnownBankAccounts,
   codecForPrepareDepositRequest,
@@ -143,6 +146,11 @@ import {
   processDownloadProposal,
   processPurchasePay,
 } from "./operations/pay.js";
+import {
+  acceptPeerPushPayment,
+  checkPeerPushPayment,
+  initiatePeerToPeerPush,
+} from "./operations/peer-to-peer.js";
 import { getPendingOperations } from "./operations/pending.js";
 import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
 import {
@@ -1049,6 +1057,19 @@ async function dispatchRequestInternal(
       await importDb(ws.db.idbHandle(), req.dump);
       return [];
     }
+    case "initiatePeerPushPayment": {
+      const req = codecForInitiatePeerPushPaymentRequest().decode(payload);
+      return await initiatePeerToPeerPush(ws, req);
+    }
+    case "checkPeerPushPayment": {
+      const req = codecForCheckPeerPushPaymentRequest().decode(payload);
+      return await checkPeerPushPayment(ws, req);
+    }
+    case "acceptPeerPushPayment": {
+      const req = codecForAcceptPeerPushPaymentRequest().decode(payload);
+      await acceptPeerPushPayment(ws, req);
+      return {};
+    }
   }
   throw TalerError.fromDetail(
     TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 190746c9..43bedddd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -169,6 +169,7 @@ importers:
       ava: ^4.0.1
       big-integer: ^1.6.51
       esbuild: ^0.14.21
+      fflate: ^0.7.3
       jed: ^1.1.1
       prettier: ^2.5.1
       rimraf: ^3.0.2
@@ -176,6 +177,7 @@ importers:
       typescript: ^4.5.5
     dependencies:
       big-integer: 1.6.51
+      fflate: 0.7.3
       jed: 1.1.1
       tslib: 2.3.1
     devDependencies:

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]