gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] 02/02: wallet-core: implement accepting p2p push pay


From: gnunet
Subject: [taler-wallet-core] 02/02: wallet-core: implement accepting p2p push payments
Date: Tue, 12 Jul 2022 17:51:15 +0200

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

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

commit f11483b511ff1f839b9913c4832eee9109f67aeb
Author: Florian Dold <florian@dold.me>
AuthorDate: Tue Jul 12 17:41:14 2022 +0200

    wallet-core: implement accepting p2p push payments
---
 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             | 253 ++++++++++++++-----
 packages/taler-util/src/talerTypes.ts              |  42 ++++
 packages/taler-util/src/walletTypes.ts             |  35 +++
 packages/taler-wallet-cli/src/index.ts             |   9 +-
 .../src/integrationtests/test-peer-to-peer.ts      |  26 ++
 .../src/crypto/cryptoImplementation.ts             | 132 +++++++++-
 .../taler-wallet-core/src/crypto/cryptoTypes.ts    |  83 ++++++-
 packages/taler-wallet-core/src/db.ts               |  40 ++-
 .../src/operations/backup/import.ts                |  52 ++--
 packages/taler-wallet-core/src/operations/pay.ts   |   2 +-
 .../src/operations/peer-to-peer.ts                 | 270 +++++++++++++++++++--
 .../src/util/contractTerms.test.ts                 | 122 ----------
 .../taler-wallet-core/src/util/contractTerms.ts    | 230 ------------------
 packages/taler-wallet-core/src/wallet-api-types.ts |  11 +
 packages/taler-wallet-core/src/wallet.ts           |  17 +-
 pnpm-lock.yaml                                     |   2 +
 20 files changed, 898 insertions(+), 464 deletions(-)

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 188f5ec0..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`,
     );
   }
@@ -775,6 +793,9 @@ export enum TalerSignaturePurpose {
   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,
@@ -782,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));
@@ -815,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.
@@ -859,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(
@@ -887,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(
@@ -900,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;
   }
@@ -924,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;
   }
 }
 
@@ -967,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));
   }
@@ -1091,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(
@@ -1118,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 7afa76e9..d4de8c37 100644
--- a/packages/taler-util/src/talerTypes.ts
+++ b/packages/taler-util/src/talerTypes.ts
@@ -1832,3 +1832,45 @@ export interface PurseDeposit {
    */
   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 4b191116..245b5654 100644
--- a/packages/taler-util/src/walletTypes.ts
+++ b/packages/taler-util/src/walletTypes.ts
@@ -1263,15 +1263,50 @@ export interface PayCoinSelection {
 
 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/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-peer-to-peer.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
index 4d27f45d..5c716dc5 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts
@@ -44,10 +44,36 @@ export async function runPeerToPeerTest(t: GlobalTestState) 
{
     WalletApiOperation.InitiatePeerPushPayment,
     {
       amount: "TESTKUDOS:5",
+      partialContractTerms: {
+        summary: "Hello World",
+      },
     },
   );
 
   console.log(resp);
+
+  const checkResp = await wallet.client.call(
+    WalletApiOperation.CheckPeerPushPayment,
+    {
+      contractPriv: resp.contractPriv,
+      exchangeBaseUrl: resp.exchangeBaseUrl,
+      pursePub: resp.pursePub,
+    },
+  );
+
+  console.log(checkResp);
+
+  const acceptResp = await wallet.client.call(
+    WalletApiOperation.AcceptPeerPushPayment,
+    {
+      exchangeBaseUrl: resp.exchangeBaseUrl,
+      pursePub: resp.pursePub,
+    },
+  );
+
+  console.log(acceptResp);
+
+  await wallet.runUntilDone();
 }
 
 runPeerToPeerTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts 
b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
index 1d364183..c177a51d 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -33,10 +33,12 @@ import {
   BlindedDenominationSignature,
   bufferForUint32,
   buildSigPS,
+  bytesToString,
   CoinDepositPermission,
   CoinEnvelope,
   createHashContext,
   decodeCrock,
+  decryptContractForMerge,
   DenomKeyType,
   DepositInfo,
   ecdheGetPublic,
@@ -45,6 +47,7 @@ import {
   eddsaSign,
   eddsaVerify,
   encodeCrock,
+  encryptContractForMerge,
   ExchangeProtocolVersion,
   getRandomBytes,
   hash,
@@ -81,10 +84,17 @@ import { DenominationRecord, WireFee } from "../db.js";
 import {
   CreateRecoupRefreshReqRequest,
   CreateRecoupReqRequest,
+  DecryptContractRequest,
+  DecryptContractResponse,
   DerivedRefreshSession,
   DerivedTipPlanchet,
   DeriveRefreshSessionRequest,
   DeriveTipRequest,
+  EncryptContractRequest,
+  EncryptContractResponse,
+  EncryptedContract,
+  SignPurseMergeRequest,
+  SignPurseMergeResponse,
   SignTrackTransactionRequest,
 } from "./cryptoTypes.js";
 
@@ -185,6 +195,16 @@ export interface TalerCryptoInterface {
   signPurseDeposits(
     req: SignPurseDepositsRequest,
   ): Promise<SignPurseDepositsResponse>;
+
+  encryptContractForMerge(
+    req: EncryptContractRequest,
+  ): Promise<EncryptContractResponse>;
+
+  decryptContractForMerge(
+    req: DecryptContractRequest,
+  ): Promise<DecryptContractResponse>;
+
+  signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
 }
 
 /**
@@ -326,6 +346,21 @@ export const nullCrypto: TalerCryptoInterface = {
   ): 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
@@ -502,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,
@@ -960,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 {
@@ -1094,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);
           }
@@ -1280,6 +1320,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
     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();
@@ -1300,6 +1343,87 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
       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 52b96b1a..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 {
@@ -148,4 +153,80 @@ export interface CreateRecoupRefreshReqRequest {
   denomPub: DenominationPubKey;
   denomPubHash: string;
   denomSig: UnblindedSignature;
-}
\ No newline at end of file
+}
+
+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 8cf5170e..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;
 }
 
 /**
@@ -1675,7 +1682,6 @@ export interface BalancePerCurrencyRecord {
  * Record for a push P2P payment that this wallet initiated.
  */
 export interface PeerPushPaymentInitiationRecord {
-
   /**
    * What exchange are funds coming from?
    */
@@ -1704,18 +1710,40 @@ export interface PeerPushPaymentInitiationRecord {
    */
   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 accepted.
+ * Record for a push P2P payment that this wallet was offered.
+ *
+ * Primary key: (exchangeBaseUrl, pursePub)
  */
-export interface PeerPushPaymentAcceptanceRecord {}
+export interface PeerPushPaymentIncomingRecord {
+  exchangeBaseUrl: string;
+
+  pursePub: string;
+
+  mergePriv: string;
+
+  contractPriv: string;
+
+  timestampAccepted: TalerProtocolTimestamp;
+
+  contractTerms: PeerContractTerms;
+
+  // FIXME: add status etc.
+}
 
 export const WalletStoresV1 = {
   coins: describeStore(
@@ -1893,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
index e2ae1e66..658cbe4f 100644
--- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts
+++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts
@@ -18,25 +18,47 @@
  * Imports.
  */
 import {
+  AbsoluteTime,
+  AcceptPeerPushPaymentRequest,
   AmountJson,
   Amounts,
-  Logger,
-  InitiatePeerPushPaymentResponse,
+  AmountString,
+  buildCodecForObject,
+  CheckPeerPushPaymentRequest,
+  CheckPeerPushPaymentResponse,
+  Codec,
+  codecForAmountString,
+  codecForAny,
+  codecForExchangeGetContractResponse,
+  ContractTermsUtil,
+  decodeCrock,
+  Duration,
+  eddsaGetPublic,
+  encodeCrock,
+  ExchangePurseMergeRequest,
   InitiatePeerPushPaymentRequest,
-  strcmp,
-  CoinPublicKeyString,
+  InitiatePeerPushPaymentResponse,
   j2s,
-  getRandomBytes,
-  Duration,
-  durationAdd,
+  Logger,
+  strcmp,
   TalerProtocolTimestamp,
-  AbsoluteTime,
-  encodeCrock,
-  AmountString,
   UnblindedSignature,
+  WalletAccountMergeFlags,
 } from "@gnu-taler/taler-util";
-import { CoinStatus } from "../db.js";
+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");
 
@@ -176,14 +198,22 @@ export async function initiatePeerToPeerPush(
 
   const pursePair = await ws.cryptoApi.createEddsaKeypair({});
   const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-  const hContractTerms = encodeCrock(getRandomBytes(64));
-  const purseExpiration = AbsoluteTime.toTimestamp(
+
+  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,
@@ -204,6 +234,13 @@ export async function initiatePeerToPeerPush(
     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,
@@ -212,11 +249,216 @@ export async function initiatePeerToPeerPush(
     purse_expiration: purseExpiration,
     deposits: depositSigsResp.deposits,
     min_age: 0,
+    econtract: econtractResp.econtract,
   });
 
   const resp = await httpResp.json();
 
   logger.info(`resp: ${j2s(resp)}`);
 
-  throw Error("not yet implemented");
+  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 5c0882ae..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,
@@ -286,6 +289,14 @@ export type WalletOperations = {
     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 d072f9e9..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,
@@ -144,7 +146,11 @@ import {
   processDownloadProposal,
   processPurchasePay,
 } from "./operations/pay.js";
-import { initiatePeerToPeerPush } from "./operations/peer-to-peer.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 {
@@ -1055,6 +1061,15 @@ async function dispatchRequestInternal(
       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]