diff --git a/lerna.json b/lerna.json index e127f42786..5146708c81 100644 --- a/lerna.json +++ b/lerna.json @@ -6,7 +6,8 @@ "packages/ripple-keypairs", "packages/ripple-address-codec", "packages/isomorphic", - "packages/secret-numbers" + "packages/secret-numbers", + "packages/mpt-crypto" ], "npmClient": "npm", "$schema": "node_modules/lerna/schemas/lerna-schema.json" diff --git a/package-lock.json b/package-lock.json index cc7137cad2..79cb704872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5331,6 +5331,10 @@ "resolved": "packages/isomorphic", "link": true }, + "node_modules/@xrplf/mpt-crypto": { + "resolved": "packages/mpt-crypto", + "link": true + }, "node_modules/@xrplf/prettier-config": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@xrplf/prettier-config/-/prettier-config-1.9.1.tgz", @@ -19196,6 +19200,14 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "packages/mpt-crypto": { + "name": "@xrplf/mpt-crypto", + "version": "0.1.0", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "packages/ripple-address-codec": { "version": "5.0.1", "license": "ISC", @@ -19271,6 +19283,14 @@ }, "engines": { "node": ">=20.19.0" + }, + "peerDependencies": { + "@xrplf/mpt-crypto": "^0.1.0" + }, + "peerDependenciesMeta": { + "@xrplf/mpt-crypto": { + "optional": true + } } }, "packages/xrpl/node_modules/agent-base": { diff --git a/packages/mpt-crypto/README.md b/packages/mpt-crypto/README.md new file mode 100644 index 0000000000..4769c6388f --- /dev/null +++ b/packages/mpt-crypto/README.md @@ -0,0 +1,63 @@ +# @xrplf/mpt-crypto + +Cryptographic primitives for **Confidential MPT (XLS-0096)** on the XRP Ledger, +exposed as a small **hex-in / hex-out** TypeScript API over a vendored +WebAssembly build of the reference C crypto library. + +This package is an **optional peer dependency** of `xrpl`. Most `xrpl` users +never need it — it is only required (and lazily loaded) by the +[`xrpl/confidential`](../xrpl/src/confidential) builders, which assemble +Confidential MPT transactions. You typically interact with those builders rather +than calling this package directly. + +## What it provides + +Confidential MPT replaces a public MPT balance with **EC-ElGamal ciphertexts** +on-ledger and uses **zero-knowledge proofs** so validators can verify transfers +(no overdraft, amounts conserved) without seeing the amounts. This package +exposes the building blocks: + +- **Encryption** — `encryptAmount`, `decryptAmount` (EC-ElGamal over secp256k1). +- **Commitments** — `getPedersenCommitment`, `generateBlindingFactor`. +- **Context hashes** — `getConvertContextHash`, `getConvertBackContextHash`, + `getSendContextHash`, `getClawbackContextHash`. Each binds a proof to a + specific transaction (account, issuance, sequence, …). +- **Proofs** — `getConvertProof`, `getConvertBackProof`, + `getConfidentialSendProof`, `getClawbackProof`. +- **Constants** — the fixed byte sizes (`PUBKEY_SIZE`, `ELGAMAL_TOTAL_SIZE`, the + per-transaction proof sizes, …) and the `bytesToHex` / `hexToBytes` helpers. + +## Conventions + +- Every byte argument and return value is an **uppercase, even-length hex + string** with no `0x` prefix (matching the rest of `xrpl.js`). +- Integer amounts are **`bigint`**, to losslessly carry the full `uint64_t` + range. +- Keys are a **secp256k1 keypair** (32-byte private key, 33-byte compressed + public key) — the same curve as a secp256k1 signing key, but a distinct key + used only for encryption. Generate one with `ripple-keypairs` + (`deriveKeypair(generateSeed({ algorithm: 'ecdsa-secp256k1' }))`). +- The WASM module is loaded once and cached on first use, so depending on this + package costs nothing until a confidential operation is actually invoked. + +## Usage + +```ts +import { + encryptAmount, + decryptAmount, + generateBlindingFactor, +} from '@xrplf/mpt-crypto' + +const blinding = await generateBlindingFactor() +const ciphertext = await encryptAmount(1000n, publicKey, blinding) +const amount = await decryptAmount(ciphertext, privateKey) // 1000n +``` + +## The vendored WASM + +`wasm/mpt_crypto.{js,wasm}` is a committed Emscripten build of the reference +`mpt-crypto` C library. **It must stay in lockstep with the `mpt-crypto` version +that `rippled` pins** — a mismatch produces valid-looking transactions that +`rippled` rejects with `tecBAD_PROOF`. When updating, rebuild from the same +`mpt-crypto` tag rippled uses and re-vendor both files. diff --git a/packages/mpt-crypto/eslint.config.js b/packages/mpt-crypto/eslint.config.js new file mode 100644 index 0000000000..1d5a698c0d --- /dev/null +++ b/packages/mpt-crypto/eslint.config.js @@ -0,0 +1,66 @@ +const globals = require('globals') +const eslintConfig = require('@xrplf/eslint-config/base').default +const tseslint = require('typescript-eslint') + +module.exports = [ + { + ignores: [ + '**/node_modules/', + '**/dist/', + 'coverage/', + '.nyc_output/', + 'nyc.config.js', + '.idea/', + '**/*.js', + 'wasm/', + 'examples/', + ], + }, + ...eslintConfig, + { + languageOptions: { + sourceType: 'module', // Allow the use of imports / ES modules + ecmaVersion: 2020, + parser: tseslint.parser, // Make ESLint compatible with TypeScript + parserOptions: { + // Enable linting rules with type information from our tsconfig + tsconfigRootDir: __dirname, + project: ['./tsconfig.eslint.json'], + ecmaFeatures: { + impliedStrict: true, // Enable global strict mode + }, + }, + + globals: { + ...globals.browser, + ...globals.node, + ...globals.es2020, + }, + }, + + rules: { + // This creates a lot of false positives. We should turn this off in our + // general config. + 'jsdoc/require-description-complete-sentence': 'off', + + // ** TODO ** + // all of the below are turned off for now during the migration to a + // monorepo. They need to actually be addressed! + // ** + '@typescript-eslint/no-magic-numbers': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-param-names': 'off', + 'jsdoc/require-throws': 'off', + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/check-examples': 'off', // Not implemented in eslint 8 + 'tsdoc/syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + + // Disabled to match the other workspace packages: under flat config this + // rule errors without an .eslintrc, and every export here is consumed by + // external packages (which the rule cannot see). + 'import/no-unused-modules': 'off', + }, + }, +] diff --git a/packages/mpt-crypto/package.json b/packages/mpt-crypto/package.json new file mode 100644 index 0000000000..c94dfc7518 --- /dev/null +++ b/packages/mpt-crypto/package.json @@ -0,0 +1,36 @@ +{ + "name": "@xrplf/mpt-crypto", + "version": "0.1.0", + "private": true, + "description": "Confidential MPT (XLS-0096) cryptographic primitives for the XRP Ledger, compiled to WebAssembly", + "files": [ + "dist/*", + "wasm/*", + "src/*" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "ISC", + "keywords": [ + "ripple", + "xrp", + "xrp ledger", + "xrpl", + "mpt", + "confidential" + ], + "repository": { + "type": "git", + "url": "git@github.com:XRPLF/xrpl.js.git" + }, + "scripts": { + "build": "tsc --build tsconfig.build.json", + "test": "jest --passWithNoTests --verbose false --silent=false ./test/*.test.ts", + "lint": "eslint . --max-warnings 0", + "clean": "rm -rf ./dist ./coverage tsconfig.build.tsbuildinfo" + }, + "prettier": "@xrplf/prettier-config", + "engines": { + "node": ">= 18" + } +} diff --git a/packages/mpt-crypto/src/constants.ts b/packages/mpt-crypto/src/constants.ts new file mode 100644 index 0000000000..c6879f1082 --- /dev/null +++ b/packages/mpt-crypto/src/constants.ts @@ -0,0 +1,52 @@ +/** + * Byte sizes mirroring `mpt-crypto/include/mpt_protocol.h`. These are the + * authoritative wire/buffer sizes for the Confidential MPT (XLS-0096) crypto + * primitives compiled into the vendored WASM module. + */ + +/** secp256k1 compressed public / ElGamal key. */ +export const PUBKEY_SIZE = 33 +/** secp256k1 private key. */ +export const PRIVKEY_SIZE = 32 +/** ElGamal randomness / Pedersen blinding factor scalar. */ +export const BLINDING_FACTOR_SIZE = 32 +/** A full ElGamal ciphertext (C1 || C2). */ +export const ELGAMAL_TOTAL_SIZE = 66 +/** A Pedersen commitment point. */ +export const PEDERSEN_COMMIT_SIZE = 33 +/** The 32-byte transaction context hash (challenge) consumed by the proofs. */ +export const CONTEXT_HASH_SIZE = 32 +/** 20-byte XRPL AccountID. */ +export const ACCOUNT_ID_SIZE = 20 +/** 24-byte MPTokenIssuanceID. */ +export const ISSUANCE_ID_SIZE = 24 + +/** ConfidentialMPTConvert ZKProof length. */ +export const CONVERT_PROOF_SIZE = 64 +/** ConfidentialMPTClawback ZKProof length. */ +export const CLAWBACK_PROOF_SIZE = 64 +/** ConfidentialMPTConvertBack ZKProof length (128 sigma + 688 bulletproof). */ +export const CONVERT_BACK_PROOF_SIZE = 816 +/** ConfidentialMPTSend ZKProof length (192 sigma + 754 bulletproof). */ +export const SEND_PROOF_SIZE = 946 + +/** + * In-memory layout of the C `mpt_confidential_participant` struct + * (`{ uint8_t pubkey[33]; uint8_t ciphertext[66]; }`, alignment 1). + */ +export const PARTICIPANT_PUBKEY_OFFSET = 0 +export const PARTICIPANT_CIPHERTEXT_OFFSET = PUBKEY_SIZE +export const PARTICIPANT_STRUCT_SIZE = PUBKEY_SIZE + ELGAMAL_TOTAL_SIZE + +/** + * In-memory layout of the C `mpt_pedersen_proof_params` struct: + * `{ uint8_t pedersen_commitment[33]; uint64_t amount; uint8_t ciphertext[66]; + * uint8_t blinding_factor[32]; }`. The `uint64_t` forces 8-byte alignment, + * so the commitment is padded from 33 to 40 and the struct size is rounded up + * to a multiple of 8. + */ +export const PEDERSEN_PARAMS_COMMITMENT_OFFSET = 0 +export const PEDERSEN_PARAMS_AMOUNT_OFFSET = 40 +export const PEDERSEN_PARAMS_CIPHERTEXT_OFFSET = 48 +export const PEDERSEN_PARAMS_BLINDING_OFFSET = 114 +export const PEDERSEN_PARAMS_STRUCT_SIZE = 152 diff --git a/packages/mpt-crypto/src/context.ts b/packages/mpt-crypto/src/context.ts new file mode 100644 index 0000000000..710d8890eb --- /dev/null +++ b/packages/mpt-crypto/src/context.ts @@ -0,0 +1,155 @@ +/* eslint-disable max-params -- context-hash builders mirror the C ABI argument lists */ +import { + ACCOUNT_ID_SIZE, + CONTEXT_HASH_SIZE, + ISSUANCE_ID_SIZE, +} from './constants' +import { bytesToHex, hexToBytes } from './hex' +import { withModule } from './runtime' + +/** + * Context hash bound to a ConfidentialMPTConvert transaction. + * + * @param account - The 20-byte hex AccountID of the converting holder. + * @param issuance - The 24-byte hex MPTokenIssuanceID. + * @param sequence - The transaction sequence number. + * @returns The 32-byte hex context hash. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getConvertContextHash( + account: string, + issuance: string, + sequence: number, +): Promise { + const acc = hexToBytes(account, 'account', ACCOUNT_ID_SIZE) + const iss = hexToBytes(issuance, 'issuance', ISSUANCE_ID_SIZE) + return withModule((mod, marshaller) => { + const accPtr = marshaller.allocBytes(acc) + const issPtr = marshaller.allocBytes(iss) + const outPtr = marshaller.alloc(CONTEXT_HASH_SIZE) + if ( + mod._mpt_get_convert_context_hash(accPtr, issPtr, sequence, outPtr) !== 0 + ) { + throw new Error('mpt_get_convert_context_hash failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONTEXT_HASH_SIZE)) + }) +} + +/** + * Context hash bound to a ConfidentialMPTConvertBack transaction. + * + * @param account - The 20-byte hex AccountID of the holder. + * @param issuance - The 24-byte hex MPTokenIssuanceID. + * @param sequence - The transaction sequence number. + * @param version - The confidential balance version. + * @returns The 32-byte hex context hash. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getConvertBackContextHash( + account: string, + issuance: string, + sequence: number, + version: number, +): Promise { + const acc = hexToBytes(account, 'account', ACCOUNT_ID_SIZE) + const iss = hexToBytes(issuance, 'issuance', ISSUANCE_ID_SIZE) + return withModule((mod, marshaller) => { + const accPtr = marshaller.allocBytes(acc) + const issPtr = marshaller.allocBytes(iss) + const outPtr = marshaller.alloc(CONTEXT_HASH_SIZE) + if ( + mod._mpt_get_convert_back_context_hash( + accPtr, + issPtr, + sequence, + version, + outPtr, + ) !== 0 + ) { + throw new Error('mpt_get_convert_back_context_hash failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONTEXT_HASH_SIZE)) + }) +} + +/** + * Context hash bound to a ConfidentialMPTSend transaction. + * + * @param account - The 20-byte hex AccountID of the sender. + * @param issuance - The 24-byte hex MPTokenIssuanceID. + * @param sequence - The transaction sequence number. + * @param destination - The 20-byte hex AccountID of the destination. + * @param version - The confidential balance version. + * @returns The 32-byte hex context hash. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getSendContextHash( + account: string, + issuance: string, + sequence: number, + destination: string, + version: number, +): Promise { + const acc = hexToBytes(account, 'account', ACCOUNT_ID_SIZE) + const iss = hexToBytes(issuance, 'issuance', ISSUANCE_ID_SIZE) + const dest = hexToBytes(destination, 'destination', ACCOUNT_ID_SIZE) + return withModule((mod, marshaller) => { + const accPtr = marshaller.allocBytes(acc) + const issPtr = marshaller.allocBytes(iss) + const destPtr = marshaller.allocBytes(dest) + const outPtr = marshaller.alloc(CONTEXT_HASH_SIZE) + if ( + mod._mpt_get_send_context_hash( + accPtr, + issPtr, + sequence, + destPtr, + version, + outPtr, + ) !== 0 + ) { + throw new Error('mpt_get_send_context_hash failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONTEXT_HASH_SIZE)) + }) +} + +/** + * Context hash bound to a ConfidentialMPTClawback transaction. + * + * @param account - The 20-byte hex AccountID of the issuer. + * @param issuance - The 24-byte hex MPTokenIssuanceID. + * @param sequence - The transaction sequence number. + * @param holder - The 20-byte hex AccountID of the holder being clawed back. + * @returns The 32-byte hex context hash. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getClawbackContextHash( + account: string, + issuance: string, + sequence: number, + holder: string, +): Promise { + const acc = hexToBytes(account, 'account', ACCOUNT_ID_SIZE) + const iss = hexToBytes(issuance, 'issuance', ISSUANCE_ID_SIZE) + const hold = hexToBytes(holder, 'holder', ACCOUNT_ID_SIZE) + return withModule((mod, marshaller) => { + const accPtr = marshaller.allocBytes(acc) + const issPtr = marshaller.allocBytes(iss) + const holdPtr = marshaller.allocBytes(hold) + const outPtr = marshaller.alloc(CONTEXT_HASH_SIZE) + if ( + mod._mpt_get_clawback_context_hash( + accPtr, + issPtr, + sequence, + holdPtr, + outPtr, + ) !== 0 + ) { + throw new Error('mpt_get_clawback_context_hash failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONTEXT_HASH_SIZE)) + }) +} diff --git a/packages/mpt-crypto/src/hex.ts b/packages/mpt-crypto/src/hex.ts new file mode 100644 index 0000000000..dceaf90e2a --- /dev/null +++ b/packages/mpt-crypto/src/hex.ts @@ -0,0 +1,51 @@ +/** + * Minimal hex <-> byte helpers for the hex-in/hex-out public API. Hex strings + * are case-insensitive and must contain an even number of `[0-9a-fA-F]` + * characters with no `0x` prefix, matching the convention used throughout + * `xrpl.js` for serialized blobs. + */ + +const HEX_REGEX = /^[0-9a-fA-F]*$/u + +/** + * Decode a hex string into a Uint8Array. + * + * @param hex - The hex string to decode. + * @param label - A human-readable name used in error messages. + * @param expectedBytes - Optional exact byte length the result must have. + * @returns The decoded bytes. + * @throws If `hex` is malformed or has the wrong length. + */ +export function hexToBytes( + hex: string, + label: string, + expectedBytes?: number, +): Uint8Array { + if (typeof hex !== 'string' || hex.length % 2 !== 0 || !HEX_REGEX.test(hex)) { + throw new Error(`${label} must be an even-length hex string`) + } + const bytes = new Uint8Array(hex.length / 2) + for (let index = 0; index < bytes.length; index++) { + bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16) + } + if (expectedBytes != null && bytes.length !== expectedBytes) { + throw new Error( + `${label} must be ${expectedBytes} bytes (got ${bytes.length})`, + ) + } + return bytes +} + +/** + * Encode a Uint8Array as an uppercase hex string. + * + * @param bytes - The bytes to encode. + * @returns The uppercase hex representation. + */ +export function bytesToHex(bytes: Uint8Array): string { + let out = '' + for (const byte of bytes) { + out += byte.toString(16).padStart(2, '0') + } + return out.toUpperCase() +} diff --git a/packages/mpt-crypto/src/index.ts b/packages/mpt-crypto/src/index.ts new file mode 100644 index 0000000000..f9889974a1 --- /dev/null +++ b/packages/mpt-crypto/src/index.ts @@ -0,0 +1,54 @@ +/** + * `@xrplf/mpt-crypto` — Confidential MPT (XLS-0096) cryptographic primitives for + * the XRP Ledger, exposed as a hex-in/hex-out API over a vendored WebAssembly + * build of the reference C library. + * + * Every byte argument and return value is an uppercase, even-length hex string + * (no `0x` prefix); integer amounts are `bigint`. The WASM module is loaded + * lazily and cached on first use, so this package can be optionally depended on + * and only pays its load cost when a confidential operation is actually invoked. + */ + +export { + PUBKEY_SIZE, + PRIVKEY_SIZE, + BLINDING_FACTOR_SIZE, + ELGAMAL_TOTAL_SIZE, + PEDERSEN_COMMIT_SIZE, + CONTEXT_HASH_SIZE, + ACCOUNT_ID_SIZE, + ISSUANCE_ID_SIZE, + CONVERT_PROOF_SIZE, + CLAWBACK_PROOF_SIZE, + CONVERT_BACK_PROOF_SIZE, + SEND_PROOF_SIZE, +} from './constants' +export { bytesToHex, hexToBytes } from './hex' +export { loadWasmModule } from './module' +export type { + Keypair, + Participant, + PedersenParams, + SendProofParams, +} from './types' + +export { + generateBlindingFactor, + encryptAmount, + decryptAmount, + getPedersenCommitment, +} from './primitives' + +export { + getConvertContextHash, + getConvertBackContextHash, + getSendContextHash, + getClawbackContextHash, +} from './context' + +export { + getConvertProof, + getClawbackProof, + getConvertBackProof, + getConfidentialSendProof, +} from './proofs' diff --git a/packages/mpt-crypto/src/internal.ts b/packages/mpt-crypto/src/internal.ts new file mode 100644 index 0000000000..efeb12750e --- /dev/null +++ b/packages/mpt-crypto/src/internal.ts @@ -0,0 +1,67 @@ +import { + BLINDING_FACTOR_SIZE, + ELGAMAL_TOTAL_SIZE, + PEDERSEN_COMMIT_SIZE, + PUBKEY_SIZE, +} from './constants' +import { hexToBytes } from './hex' +import { RawParticipant, RawPedersenParams } from './marshal' +import { Participant, PedersenParams } from './types' + +/** + * Decode a hex-encoded {@link Participant} into its byte-struct form. + * + * @param participant - The hex participant to decode. + * @param label - A human-readable name used in error messages. + * @returns The decoded {@link RawParticipant}. + * @throws If either field is malformed or the wrong length. + */ +export function rawParticipant( + participant: Participant, + label: string, +): RawParticipant { + return { + publicKey: hexToBytes( + participant.publicKey, + `${label}.publicKey`, + PUBKEY_SIZE, + ), + ciphertext: hexToBytes( + participant.ciphertext, + `${label}.ciphertext`, + ELGAMAL_TOTAL_SIZE, + ), + } +} + +/** + * Decode a hex-encoded {@link PedersenParams} into its byte-struct form. + * + * @param params - The hex Pedersen witness to decode. + * @param label - A human-readable name used in error messages. + * @returns The decoded {@link RawPedersenParams}. + * @throws If any field is malformed or the wrong length. + */ +export function rawPedersenParams( + params: PedersenParams, + label: string, +): RawPedersenParams { + return { + commitment: hexToBytes( + params.commitment, + `${label}.commitment`, + PEDERSEN_COMMIT_SIZE, + ), + amount: params.amount, + ciphertext: hexToBytes( + params.ciphertext, + `${label}.ciphertext`, + ELGAMAL_TOTAL_SIZE, + ), + blindingFactor: hexToBytes( + params.blindingFactor, + `${label}.blindingFactor`, + BLINDING_FACTOR_SIZE, + ), + } +} diff --git a/packages/mpt-crypto/src/marshal.ts b/packages/mpt-crypto/src/marshal.ts new file mode 100644 index 0000000000..73183fb623 --- /dev/null +++ b/packages/mpt-crypto/src/marshal.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/naming-convention -- internal WASM helper */ +import { + PARTICIPANT_CIPHERTEXT_OFFSET, + PARTICIPANT_PUBKEY_OFFSET, + PARTICIPANT_STRUCT_SIZE, + PEDERSEN_PARAMS_AMOUNT_OFFSET, + PEDERSEN_PARAMS_BLINDING_OFFSET, + PEDERSEN_PARAMS_CIPHERTEXT_OFFSET, + PEDERSEN_PARAMS_COMMITMENT_OFFSET, + PEDERSEN_PARAMS_STRUCT_SIZE, +} from './constants' +import { WasmModule } from './module' + +/** + * Byte-level view of a `mpt_confidential_participant` struct, as consumed by + * {@link Marshaller.allocParticipants}. This is the internal counterpart of the + * hex-based public `Participant` type. + */ +export interface RawParticipant { + publicKey: Uint8Array + ciphertext: Uint8Array +} + +/** + * Byte-level view of a `mpt_pedersen_proof_params` struct, as consumed by + * {@link Marshaller.allocPedersenParams}. Internal counterpart of the hex-based + * public `PedersenParams` type. + */ +export interface RawPedersenParams { + commitment: Uint8Array + amount: bigint + ciphertext: Uint8Array + blindingFactor: Uint8Array +} + +/** + * Scratch-memory helper bound to a single {@link WasmModule} instance. Tracks + * every allocation so a call site can release all of them at once via + * {@link Marshaller.dispose}. A fresh {@link DataView}/`HEAPU8` is taken on each + * access because the module is built with `ALLOW_MEMORY_GROWTH=1`, which can + * replace the underlying `ArrayBuffer` after any `_malloc`. + */ +export class Marshaller { + private readonly mod: WasmModule + private readonly ptrs: number[] = [] + + public constructor(mod: WasmModule) { + this.mod = mod + } + + /** Allocate `size` bytes of zero-initialized scratch memory. */ + public alloc(size: number): number { + const ptr = this.mod._malloc(size) + this.mod.HEAPU8.fill(0, ptr, ptr + size) + this.ptrs.push(ptr) + return ptr + } + + /** Allocate and copy `data` into WASM memory; returns the pointer. */ + public allocBytes(data: Uint8Array): number { + const ptr = this.mod._malloc(data.length) + this.mod.HEAPU8.set(data, ptr) + this.ptrs.push(ptr) + return ptr + } + + /** Copy `len` bytes back out of WASM memory into a detached Uint8Array. */ + public readBytes(ptr: number, len: number): Uint8Array { + return this.mod.HEAPU8.slice(ptr, ptr + len) + } + + private view(): DataView { + return new DataView(this.mod.HEAPU8.buffer) + } + + /** Write a little-endian uint32 at `ptr`. */ + public writeU32(ptr: number, value: number): void { + this.view().setUint32(ptr, value, true) + } + + /** Read a little-endian uint32 at `ptr`. */ + public readU32(ptr: number): number { + return this.view().getUint32(ptr, true) + } + + /** Read a little-endian uint64 at `ptr`. */ + public readU64(ptr: number): bigint { + return this.view().getBigUint64(ptr, true) + } + + /** Allocate and populate an `mpt_pedersen_proof_params` struct. */ + public allocPedersenParams(params: RawPedersenParams): number { + const ptr = this.alloc(PEDERSEN_PARAMS_STRUCT_SIZE) + this.mod.HEAPU8.set( + params.commitment, + ptr + PEDERSEN_PARAMS_COMMITMENT_OFFSET, + ) + this.view().setBigUint64( + ptr + PEDERSEN_PARAMS_AMOUNT_OFFSET, + params.amount, + true, + ) + this.mod.HEAPU8.set( + params.ciphertext, + ptr + PEDERSEN_PARAMS_CIPHERTEXT_OFFSET, + ) + this.mod.HEAPU8.set( + params.blindingFactor, + ptr + PEDERSEN_PARAMS_BLINDING_OFFSET, + ) + return ptr + } + + /** Allocate and populate a contiguous array of participant structs. */ + public allocParticipants(participants: RawParticipant[]): number { + const ptr = this.alloc(PARTICIPANT_STRUCT_SIZE * participants.length) + participants.forEach((participant, index) => { + const base = ptr + index * PARTICIPANT_STRUCT_SIZE + this.mod.HEAPU8.set( + participant.publicKey, + base + PARTICIPANT_PUBKEY_OFFSET, + ) + this.mod.HEAPU8.set( + participant.ciphertext, + base + PARTICIPANT_CIPHERTEXT_OFFSET, + ) + }) + return ptr + } + + /** Free every allocation made through this marshaller. */ + public dispose(): void { + for (const ptr of this.ptrs) { + this.mod._free(ptr) + } + this.ptrs.length = 0 + } +} diff --git a/packages/mpt-crypto/src/module.ts b/packages/mpt-crypto/src/module.ts new file mode 100644 index 0000000000..d32b157dd2 --- /dev/null +++ b/packages/mpt-crypto/src/module.ts @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/naming-convention -- WASM exports keep their C names */ +/* eslint-disable max-params -- this interface mirrors the C ABI; many byte-pointer args are inherent */ +/* eslint-disable n/global-require -- runtime-resolved Emscripten glue */ +/* eslint-disable @typescript-eslint/no-var-requires -- runtime-resolved Emscripten glue */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment -- Emscripten factory typed via ModuleFactory */ + +/** + * Typed view of the Emscripten-generated `mpt_crypto` WASM module. Only the + * exports vendored by `build_emcc.sh` are declared. All `uint64_t` parameters + * are passed as JS `bigint` (the module is built with `-sWASM_BIGINT=1`); all + * pointer parameters are byte offsets into {@link WasmModule.HEAPU8}. + * + * The C `account_id` / `mpt_issuance_id` by-value struct parameters of the + * context-hash functions are lowered by the wasm32 ABI to pointers, so they are + * declared as `number` here (verified against the reference harness). + */ +export interface WasmModule { + HEAPU8: Uint8Array + _malloc: (size: number) => number + _free: (ptr: number) => void + _mpt_secp256k1_context: () => number + + _mpt_generate_blinding_factor: (outFactor: number) => number + _mpt_encrypt_amount: ( + amount: bigint, + pubkey: number, + blinding: number, + outCiphertext: number, + ) => number + _mpt_decrypt_amount: ( + ciphertext: number, + privkey: number, + outAmount: number, + ) => number + _mpt_get_pedersen_commitment: ( + amount: bigint, + blinding: number, + outCommitment: number, + ) => number + + _mpt_get_convert_context_hash: ( + account: number, + issuance: number, + sequence: number, + outHash: number, + ) => number + _mpt_get_convert_back_context_hash: ( + account: number, + issuance: number, + sequence: number, + version: number, + outHash: number, + ) => number + _mpt_get_send_context_hash: ( + account: number, + issuance: number, + sequence: number, + destination: number, + version: number, + outHash: number, + ) => number + _mpt_get_clawback_context_hash: ( + account: number, + issuance: number, + sequence: number, + holder: number, + outHash: number, + ) => number + + _mpt_get_convert_proof: ( + pubkey: number, + privkey: number, + contextHash: number, + outProof: number, + ) => number + _mpt_get_clawback_proof: ( + privkey: number, + pubkey: number, + contextHash: number, + amount: bigint, + ciphertext: number, + outProof: number, + ) => number + _mpt_get_convert_back_proof: ( + privkey: number, + pubkey: number, + contextHash: number, + amount: bigint, + params: number, + outProof: number, + ) => number + _mpt_get_confidential_send_proof: ( + privkey: number, + pubkey: number, + amount: bigint, + participants: number, + nParticipants: number, + txBlindingFactor: number, + contextHash: number, + amountCommitment: number, + balanceParams: number, + outProof: number, + outLen: number, + ) => number +} + +// eslint-disable-next-line @typescript-eslint/no-type-alias -- the Emscripten module factory's call signature +type ModuleFactory = (args?: Record) => Promise + +let cached: Promise | undefined + +/** + * Load (once) and return the vendored WASM module. The Emscripten glue locates + * `mpt_crypto.wasm` next to its own `.js` file, so the vendored `wasm/` folder + * must ship alongside the compiled output. + * + * @returns A promise resolving to the initialized WASM module. + */ +export async function loadWasmModule(): Promise { + cached ??= (async (): Promise => { + const factory: ModuleFactory = require('../wasm/mpt_crypto') + const instance = await factory() + // Force one-time initialization of the shared secp256k1 context. + instance._mpt_secp256k1_context() + return instance + })() + return cached +} diff --git a/packages/mpt-crypto/src/primitives.ts b/packages/mpt-crypto/src/primitives.ts new file mode 100644 index 0000000000..ab3d45d464 --- /dev/null +++ b/packages/mpt-crypto/src/primitives.ts @@ -0,0 +1,110 @@ +import { + BLINDING_FACTOR_SIZE, + ELGAMAL_TOTAL_SIZE, + PEDERSEN_COMMIT_SIZE, + PRIVKEY_SIZE, + PUBKEY_SIZE, +} from './constants' +import { bytesToHex, hexToBytes } from './hex' +import { withModule } from './runtime' + +const U64_BYTES = 8 + +/** + * Generate a 32-byte blinding factor / ElGamal randomness scalar. + * + * @returns The hex-encoded blinding factor. + * @throws If the underlying WASM call fails. + */ +export async function generateBlindingFactor(): Promise { + return withModule((mod, marshaller) => { + const ptr = marshaller.alloc(BLINDING_FACTOR_SIZE) + if (mod._mpt_generate_blinding_factor(ptr) !== 0) { + throw new Error('mpt_generate_blinding_factor failed') + } + return bytesToHex(marshaller.readBytes(ptr, BLINDING_FACTOR_SIZE)) + }) +} + +/** + * ElGamal-encrypt an amount under a public key. + * + * @param amount - The integer amount to encrypt. + * @param publicKey - The 33-byte hex public key. + * @param blindingFactor - The 32-byte hex randomness scalar. + * @returns The 66-byte hex ciphertext (C1 || C2). + * @throws If inputs are malformed or the WASM call fails. + */ +export async function encryptAmount( + amount: bigint, + publicKey: string, + blindingFactor: string, +): Promise { + const pub = hexToBytes(publicKey, 'publicKey', PUBKEY_SIZE) + const blinding = hexToBytes( + blindingFactor, + 'blindingFactor', + BLINDING_FACTOR_SIZE, + ) + return withModule((mod, marshaller) => { + const pubPtr = marshaller.allocBytes(pub) + const blindingPtr = marshaller.allocBytes(blinding) + const outPtr = marshaller.alloc(ELGAMAL_TOTAL_SIZE) + if (mod._mpt_encrypt_amount(amount, pubPtr, blindingPtr, outPtr) !== 0) { + throw new Error('mpt_encrypt_amount failed') + } + return bytesToHex(marshaller.readBytes(outPtr, ELGAMAL_TOTAL_SIZE)) + }) +} + +/** + * Decrypt an ElGamal ciphertext with a private key. + * + * @param ciphertext - The 66-byte hex ciphertext. + * @param privateKey - The 32-byte hex private key. + * @returns The decrypted integer amount. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function decryptAmount( + ciphertext: string, + privateKey: string, +): Promise { + const ct = hexToBytes(ciphertext, 'ciphertext', ELGAMAL_TOTAL_SIZE) + const priv = hexToBytes(privateKey, 'privateKey', PRIVKEY_SIZE) + return withModule((mod, marshaller) => { + const ctPtr = marshaller.allocBytes(ct) + const privPtr = marshaller.allocBytes(priv) + const outPtr = marshaller.alloc(U64_BYTES) + if (mod._mpt_decrypt_amount(ctPtr, privPtr, outPtr) !== 0) { + throw new Error('mpt_decrypt_amount failed') + } + return marshaller.readU64(outPtr) + }) +} + +/** + * Compute a Pedersen commitment `amount*G + blindingFactor*H`. + * + * @param amount - The integer amount to commit to. + * @param blindingFactor - The 32-byte hex blinding scalar (rho). + * @returns The 33-byte hex commitment point. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getPedersenCommitment( + amount: bigint, + blindingFactor: string, +): Promise { + const blinding = hexToBytes( + blindingFactor, + 'blindingFactor', + BLINDING_FACTOR_SIZE, + ) + return withModule((mod, marshaller) => { + const blindingPtr = marshaller.allocBytes(blinding) + const outPtr = marshaller.alloc(PEDERSEN_COMMIT_SIZE) + if (mod._mpt_get_pedersen_commitment(amount, blindingPtr, outPtr) !== 0) { + throw new Error('mpt_get_pedersen_commitment failed') + } + return bytesToHex(marshaller.readBytes(outPtr, PEDERSEN_COMMIT_SIZE)) + }) +} diff --git a/packages/mpt-crypto/src/proofs.ts b/packages/mpt-crypto/src/proofs.ts new file mode 100644 index 0000000000..2ef55a7366 --- /dev/null +++ b/packages/mpt-crypto/src/proofs.ts @@ -0,0 +1,197 @@ +/* eslint-disable max-params, max-lines-per-function -- proof builders mirror the C ABI */ +import { + BLINDING_FACTOR_SIZE, + CLAWBACK_PROOF_SIZE, + CONVERT_BACK_PROOF_SIZE, + CONVERT_PROOF_SIZE, + CONTEXT_HASH_SIZE, + ELGAMAL_TOTAL_SIZE, + PEDERSEN_COMMIT_SIZE, + PRIVKEY_SIZE, + PUBKEY_SIZE, + SEND_PROOF_SIZE, +} from './constants' +import { bytesToHex, hexToBytes } from './hex' +import { rawParticipant, rawPedersenParams } from './internal' +import { withModule } from './runtime' +import { PedersenParams, SendProofParams } from './types' + +const SIZE_T_BYTES = 4 + +/** + * Generate the 64-byte Schnorr proof for a ConfidentialMPTConvert transaction. + * + * @param publicKey - The 33-byte hex public key. + * @param privateKey - The 32-byte hex private key. + * @param contextHash - The 32-byte hex transaction context hash. + * @returns The 64-byte hex proof. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getConvertProof( + publicKey: string, + privateKey: string, + contextHash: string, +): Promise { + const pub = hexToBytes(publicKey, 'publicKey', PUBKEY_SIZE) + const priv = hexToBytes(privateKey, 'privateKey', PRIVKEY_SIZE) + const ctx = hexToBytes(contextHash, 'contextHash', CONTEXT_HASH_SIZE) + return withModule((mod, marshaller) => { + const pubPtr = marshaller.allocBytes(pub) + const privPtr = marshaller.allocBytes(priv) + const ctxPtr = marshaller.allocBytes(ctx) + const outPtr = marshaller.alloc(CONVERT_PROOF_SIZE) + if (mod._mpt_get_convert_proof(pubPtr, privPtr, ctxPtr, outPtr) !== 0) { + throw new Error('mpt_get_convert_proof failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONVERT_PROOF_SIZE)) + }) +} + +/** + * Generate the 64-byte sigma proof for a ConfidentialMPTClawback transaction. + * + * @param privateKey - The issuer's 32-byte hex private key. + * @param publicKey - The issuer's 33-byte hex public key. + * @param contextHash - The 32-byte hex transaction context hash. + * @param amount - The publicly known amount being clawed back. + * @param ciphertext - The holder's 66-byte hex balance ciphertext. + * @returns The 64-byte hex proof. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getClawbackProof( + privateKey: string, + publicKey: string, + contextHash: string, + amount: bigint, + ciphertext: string, +): Promise { + const priv = hexToBytes(privateKey, 'privateKey', PRIVKEY_SIZE) + const pub = hexToBytes(publicKey, 'publicKey', PUBKEY_SIZE) + const ctx = hexToBytes(contextHash, 'contextHash', CONTEXT_HASH_SIZE) + const ct = hexToBytes(ciphertext, 'ciphertext', ELGAMAL_TOTAL_SIZE) + return withModule((mod, marshaller) => { + const privPtr = marshaller.allocBytes(priv) + const pubPtr = marshaller.allocBytes(pub) + const ctxPtr = marshaller.allocBytes(ctx) + const ctPtr = marshaller.allocBytes(ct) + const outPtr = marshaller.alloc(CLAWBACK_PROOF_SIZE) + if ( + mod._mpt_get_clawback_proof( + privPtr, + pubPtr, + ctxPtr, + amount, + ctPtr, + outPtr, + ) !== 0 + ) { + throw new Error('mpt_get_clawback_proof failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CLAWBACK_PROOF_SIZE)) + }) +} + +/** + * Generate the 816-byte proof for a ConfidentialMPTConvertBack transaction. + * + * @param privateKey - The holder's 32-byte hex private key. + * @param publicKey - The holder's 33-byte hex public key. + * @param contextHash - The 32-byte hex transaction context hash. + * @param amount - The publicly revealed conversion amount. + * @param params - The holder's balance Pedersen witness. + * @returns The 816-byte hex proof. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getConvertBackProof( + privateKey: string, + publicKey: string, + contextHash: string, + amount: bigint, + params: PedersenParams, +): Promise { + const priv = hexToBytes(privateKey, 'privateKey', PRIVKEY_SIZE) + const pub = hexToBytes(publicKey, 'publicKey', PUBKEY_SIZE) + const ctx = hexToBytes(contextHash, 'contextHash', CONTEXT_HASH_SIZE) + const rawParams = rawPedersenParams(params, 'params') + return withModule((mod, marshaller) => { + const privPtr = marshaller.allocBytes(priv) + const pubPtr = marshaller.allocBytes(pub) + const ctxPtr = marshaller.allocBytes(ctx) + const paramsPtr = marshaller.allocPedersenParams(rawParams) + const outPtr = marshaller.alloc(CONVERT_BACK_PROOF_SIZE) + if ( + mod._mpt_get_convert_back_proof( + privPtr, + pubPtr, + ctxPtr, + amount, + paramsPtr, + outPtr, + ) !== 0 + ) { + throw new Error('mpt_get_convert_back_proof failed') + } + return bytesToHex(marshaller.readBytes(outPtr, CONVERT_BACK_PROOF_SIZE)) + }) +} + +/** + * Generate the 946-byte proof for a ConfidentialMPTSend transaction. + * + * @param params - The send-proof inputs (sender keys, participants, witnesses). + * @returns The 946-byte hex proof. + * @throws If inputs are malformed or the WASM call fails. + */ +export async function getConfidentialSendProof( + params: SendProofParams, +): Promise { + const priv = hexToBytes(params.privateKey, 'privateKey', PRIVKEY_SIZE) + const pub = hexToBytes(params.publicKey, 'publicKey', PUBKEY_SIZE) + const txBlinding = hexToBytes( + params.txBlindingFactor, + 'txBlindingFactor', + BLINDING_FACTOR_SIZE, + ) + const ctx = hexToBytes(params.contextHash, 'contextHash', CONTEXT_HASH_SIZE) + const amountCommitment = hexToBytes( + params.amountCommitment, + 'amountCommitment', + PEDERSEN_COMMIT_SIZE, + ) + const participants = params.participants.map((participant, index) => + rawParticipant(participant, `participants[${index}]`), + ) + const balanceParams = rawPedersenParams(params.balanceParams, 'balanceParams') + return withModule((mod, marshaller) => { + const privPtr = marshaller.allocBytes(priv) + const pubPtr = marshaller.allocBytes(pub) + const participantsPtr = marshaller.allocParticipants(participants) + const txBlindingPtr = marshaller.allocBytes(txBlinding) + const ctxPtr = marshaller.allocBytes(ctx) + const amountCommitmentPtr = marshaller.allocBytes(amountCommitment) + const balancePtr = marshaller.allocPedersenParams(balanceParams) + const outPtr = marshaller.alloc(SEND_PROOF_SIZE) + const outLenPtr = marshaller.alloc(SIZE_T_BYTES) + marshaller.writeU32(outLenPtr, SEND_PROOF_SIZE) + if ( + mod._mpt_get_confidential_send_proof( + privPtr, + pubPtr, + params.amount, + participantsPtr, + participants.length, + txBlindingPtr, + ctxPtr, + amountCommitmentPtr, + balancePtr, + outPtr, + outLenPtr, + ) !== 0 + ) { + throw new Error('mpt_get_confidential_send_proof failed') + } + return bytesToHex( + marshaller.readBytes(outPtr, marshaller.readU32(outLenPtr)), + ) + }) +} diff --git a/packages/mpt-crypto/src/runtime.ts b/packages/mpt-crypto/src/runtime.ts new file mode 100644 index 0000000000..af678e7084 --- /dev/null +++ b/packages/mpt-crypto/src/runtime.ts @@ -0,0 +1,23 @@ +import { Marshaller } from './marshal' +import { loadWasmModule, WasmModule } from './module' + +/** + * Load the (cached) WASM module, run `fn` with a fresh {@link Marshaller}, and + * release every scratch allocation afterwards. All high-level API functions go + * through this helper so they never leak WASM heap memory, even on error. + * + * @param fn - Callback receiving the loaded module and a bound marshaller. + * @returns The value returned by `fn`. + */ +// eslint-disable-next-line import/prefer-default-export -- the package's internal execution helper; named for call-site clarity +export async function withModule( + fn: (mod: WasmModule, marshaller: Marshaller) => T, +): Promise { + const mod = await loadWasmModule() + const marshaller = new Marshaller(mod) + try { + return fn(mod, marshaller) + } finally { + marshaller.dispose() + } +} diff --git a/packages/mpt-crypto/src/types.ts b/packages/mpt-crypto/src/types.ts new file mode 100644 index 0000000000..2ceba5f774 --- /dev/null +++ b/packages/mpt-crypto/src/types.ts @@ -0,0 +1,60 @@ +/** + * Public-facing types for the `@xrplf/mpt-crypto` hex-in/hex-out API. Every + * byte field is an uppercase, even-length hex string (no `0x` prefix); integer + * amounts are `bigint` to losslessly carry the full `uint64_t` range. + */ + +/** + * An ElGamal keypair: a 32-byte private key and the corresponding 33-byte + * compressed secp256k1 public key, both hex-encoded. + */ +export interface Keypair { + privateKey: string + publicKey: string +} + +/** + * A participant in a Confidential MPT proof — a 33-byte compressed public key + * and the 66-byte ElGamal ciphertext encrypting the amount under that key. + * Mirrors the C `mpt_confidential_participant` struct. + */ +export interface Participant { + publicKey: string + ciphertext: string +} + +/** + * The witness for a Pedersen-committed value, mirroring the C + * `mpt_pedersen_proof_params` struct: the 33-byte Pedersen commitment, the + * committed integer amount, the 66-byte ElGamal ciphertext of that amount, and + * the 32-byte blinding factor (rho) used in the commitment. + */ +export interface PedersenParams { + commitment: string + amount: bigint + ciphertext: string + blindingFactor: string +} + +/** + * Inputs for {@link getConfidentialSendProof}, the 946-byte proof attached to a + * ConfidentialMPTSend transaction. + */ +export interface SendProofParams { + /** The sender's 32-byte private key. */ + privateKey: string + /** The sender's 33-byte public key. */ + publicKey: string + /** The integer amount being sent. */ + amount: bigint + /** Participants in order: sender, destination, issuer, and auditor (optional). */ + participants: Participant[] + /** The shared ElGamal randomness r, also the blinding factor for `pc_m`. */ + txBlindingFactor: string + /** The 32-byte transaction context hash. */ + contextHash: string + /** The 33-byte Pedersen commitment to the amount (`pc_m = m*G + r*H`). */ + amountCommitment: string + /** The sender's balance witness (`pc_b`, balance, b1||b2, rho). */ + balanceParams: PedersenParams +} diff --git a/packages/mpt-crypto/tsconfig.build.json b/packages/mpt-crypto/tsconfig.build.json new file mode 100644 index 0000000000..a52425aa22 --- /dev/null +++ b/packages/mpt-crypto/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/mpt-crypto/tsconfig.eslint.json b/packages/mpt-crypto/tsconfig.eslint.json new file mode 100644 index 0000000000..16dc56bd83 --- /dev/null +++ b/packages/mpt-crypto/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "test/*.test.ts"] +} diff --git a/packages/mpt-crypto/tsconfig.json b/packages/mpt-crypto/tsconfig.json new file mode 100644 index 0000000000..91f55493ae --- /dev/null +++ b/packages/mpt-crypto/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es2020", + "lib": ["es2020"], + "outDir": "./dist", + "noUnusedLocals": true, + "noUnusedParameters": true, + "removeComments": false, + "preserveConstEnums": false, + "skipLibCheck": true, + "declaration": true, + "strictNullChecks": true + }, + "files": [], + "include": ["src/**/*.ts"] +} diff --git a/packages/mpt-crypto/wasm/mpt_crypto.js b/packages/mpt-crypto/wasm/mpt_crypto.js new file mode 100644 index 0000000000..7e77678b92 --- /dev/null +++ b/packages/mpt-crypto/wasm/mpt_crypto.js @@ -0,0 +1,2 @@ +var MptCrypto=(()=>{var _scriptName=globalThis.document?.currentScript?.src;return async function(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};if(typeof __filename!="undefined"){_scriptName=__filename}else{}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");var readyPromiseResolve,readyPromiseReject;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.initialized)FS.init();TTY.init();wasmExports["u"]();FS.ignorePermissions=false}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("mpt_crypto.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.slice(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.slice(0,-1)}return root+dir},basename:path=>path&&path.match(/([^\/]+|\/)\/*$/)[1],join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(ENVIRONMENT_IS_NODE){var nodeCrypto=require("node:crypto");return view=>nodeCrypto.randomFillSync(view)}return view=>crypto.getRandomValues(view)};var randomFill=view=>{(randomFill=initRandomFill())(view)};var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).slice(1);to=PATH_FS.resolve(to).slice(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);return UTF8Decoder.decode(heapOrArray.buffer?heapOrArray.subarray(idx,endPtr):new Uint8Array(heapOrArray.slice(idx,endPtr)))};var FS_stdin_getChar_buffer=[];var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var intArrayFromString=(stringy,dontAddNull,length)=>{var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array};var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(globalThis.window?.prompt){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output?.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var mmapAlloc=size=>{abort()};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16895,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.atime=node.mtime=node.ctime=Date.now();if(parent){parent.contents[name]=node;parent.atime=parent.mtime=parent.ctime=node.atime}return node},getFileDataAsTypedArray(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.atime);attr.mtime=new Date(node.mtime);attr.ctime=new Date(node.ctime);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){for(const key of["mode","atime","mtime","ctime"]){if(attr[key]!=null){node[key]=attr[key]}}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){if(!MEMFS.doesNotExistError){MEMFS.doesNotExistError=new FS.ErrnoError(44);MEMFS.doesNotExistError.stack=""}throw MEMFS.doesNotExistError},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){if(FS.isDir(old_node.mode)){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}FS.hashRemoveNode(new_node)}delete old_node.parent.contents[old_node.name];new_dir.contents[new_name]=old_node;old_node.name=new_name;new_dir.ctime=new_dir.mtime=old_node.parent.ctime=old_node.parent.mtime=Date.now()},unlink(parent,name){delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},readdir(node){return[".","..",...Object.keys(node.contents)]},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var asyncLoad=async url=>{var arrayBuffer=await readAsync(url);return new Uint8Array(arrayBuffer)};var FS_createDataFile=(...args)=>FS.createDataFile(...args);var getUniqueRunDependency=id=>id;var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var preloadPlugins=[];var FS_handledByPreloadPlugin=async(byteArray,fullname)=>{if(typeof Browser!="undefined")Browser.init();for(var plugin of preloadPlugins){if(plugin["canHandle"](fullname)){return plugin["handle"](byteArray,fullname)}}return byteArray};var FS_preloadFile=async(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);addRunDependency(dep);try{var byteArray=url;if(typeof url=="string"){byteArray=await asyncLoad(url)}byteArray=await FS_handledByPreloadPlugin(byteArray,fullname);preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}}finally{removeRunDependency(dep)}};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{FS_preloadFile(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish).then(onload).catch(onerror)};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,filesystems:null,syncFSRequests:0,ErrnoError:class{name="ErrnoError";constructor(errno){this.errno=errno}},FSStream:class{shared={};get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{node_ops={};stream_ops={};readMode=292|73;writeMode=146;mounted=null;constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.rdev=rdev;this.atime=this.mtime=this.ctime=Date.now()}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){if(!path){throw new FS.ErrnoError(44)}opts.follow_mount??=true;if(!PATH.isAbs(path)){path=FS.cwd()+"/"+path}linkloop:for(var nlinks=0;nlinks<40;nlinks++){var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}if(perms.includes("w")&&!(node.mode&146)){return 2}if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){if(!FS.isDir(dir.mode)){return 54}try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else if(FS.isDir(node.mode)){return 31}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}var mode=FS.flagsToPermissionString(flags);if(FS.isDir(node.mode)){if(mode!=="r"||flags&(512|64)){return 31}}return FS.nodePermissions(node,mode)},checkOpExists(op,err){if(!op){throw new FS.ErrnoError(err)}return op},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},doSetAttr(stream,node,attr){var setattr=stream?.stream_ops.setattr;var arg=setattr?stream:node;setattr??=node.node_ops.setattr;FS.checkOpExists(setattr,63);setattr(arg,attr)},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}for(var mount of mounts){if(mount.type.syncfs){mount.type.syncfs(mount,populate,done)}else{done(null)}}},mount(type,opts,mountpoint){var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);for(var[hash,current]of Object.entries(FS.nameTable)){while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}}node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name){throw new FS.ErrnoError(28)}if(name==="."||name===".."){throw new FS.ErrnoError(20)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},statfs(path){return FS.statfsNode(FS.lookupPath(path,{follow:true}).node)},statfsStream(stream){return FS.statfsNode(stream.node)},statfsNode(node){var rtn={bsize:4096,frsize:4096,blocks:1e6,bfree:5e5,bavail:5e5,files:FS.nextInode,ffree:FS.nextInode-1,fsid:42,flags:2,namelen:255};if(node.node_ops.statfs){Object.assign(rtn,node.node_ops.statfs(node.mount.opts.root))}return rtn},create(path,mode=438){mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode=511){mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var dir of dirs){if(!dir)continue;if(d||PATH.isAbs(path))d+="/";d+=dir;try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev(path,mode,dev){if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink(oldpath,newpath){if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename(old_path,new_path){var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name);old_node.parent=new_dir}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir(path){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var readdir=FS.checkOpExists(node.node_ops.readdir,54);return readdir(node)},unlink(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink(path){var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return link.node_ops.readlink(link)},stat(path,dontFollow){var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;var getattr=FS.checkOpExists(node.node_ops.getattr,63);return getattr(node)},fstat(fd){var stream=FS.getStreamChecked(fd);var node=stream.node;var getattr=stream.stream_ops.getattr;var arg=getattr?stream:node;getattr??=node.node_ops.getattr;FS.checkOpExists(getattr,63);return getattr(arg)},lstat(path){return FS.stat(path,true)},doChmod(stream,node,mode,dontFollow){FS.doSetAttr(stream,node,{mode:mode&4095|node.mode&~4095,ctime:Date.now(),dontFollow})},chmod(path,mode,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChmod(null,node,mode,dontFollow)},lchmod(path,mode){FS.chmod(path,mode,true)},fchmod(fd,mode){var stream=FS.getStreamChecked(fd);FS.doChmod(stream,stream.node,mode,false)},doChown(stream,node,dontFollow){FS.doSetAttr(stream,node,{timestamp:Date.now(),dontFollow})},chown(path,uid,gid,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChown(null,node,dontFollow)},lchown(path,uid,gid){FS.chown(path,uid,gid,true)},fchown(fd,uid,gid){var stream=FS.getStreamChecked(fd);FS.doChown(stream,stream.node,false)},doTruncate(stream,node,len){if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}FS.doSetAttr(stream,node,{size:len,timestamp:Date.now()})},truncate(path,len){if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}FS.doTruncate(null,node,len)},ftruncate(fd,len){var stream=FS.getStreamChecked(fd);if(len<0||(stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.doTruncate(stream,stream.node,len)},utime(path,atime,mtime){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var setattr=FS.checkOpExists(node.node_ops.setattr,63);setattr(node,{atime,mtime})},open(path,flags,mode=438){if(path===""){throw new FS.ErrnoError(44)}flags=typeof flags=="string"?FS_modeStringToFlags(flags):flags;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;var isDirPath;if(typeof path=="object"){node=path}else{isDirPath=path.endsWith("/");var lookup=FS.lookupPath(path,{follow:!(flags&131072),noent_okay:true});node=lookup.node;path=lookup.path}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else if(isDirPath){throw new FS.ErrnoError(31)}else{node=FS.mknod(path,mode|511,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node,path:FS.getPath(node),flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(created){FS.chmod(node,mode&511)}return stream},close(stream){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed(stream){return stream.fd===null},llseek(stream,offset,whence){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read(stream,buffer,offset,length,position){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){abort(`Invalid encoding type "${opts.encoding}"`)}var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){buf=UTF8ArrayToString(buf)}FS.close(stream);return buf},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){data=new Uint8Array(intArrayFromString(data,true))}if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{abort("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length,llseek:()=>0});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomFill(randomBuffer);randomLeft=randomBuffer.byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16895,73);node.stream_ops={llseek:MEMFS.stream_ops.llseek};node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path},id:fd+1};ret.parent=ret;return ret},readdir(){return Array.from(FS.streams.entries()).filter(([k,v])=>v).map(([k,v])=>k.toString())}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},staticInit(){FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;for(var stream of FS.streams){if(stream){FS.close(stream)}}},findObject(path,dontResolveLastLink){var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath(path,dontResolveLastLink){try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath(parent,path,canRead,canWrite){parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){if(e.errno!=20)throw e}parent=current}return current},createFile(parent,name,properties,canRead,canWrite){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile(parent,name,data,canRead,canWrite,canOwn){var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data=="string"){var arr=new Array(data.length);for(var i=0,len=data.length;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)abort("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)abort("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")abort("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(globalThis.XMLHttpRequest){if(!ENVIRONMENT_IS_WORKER)abort("Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc");var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};for(const[key,fn]of Object.entries(node.stream_ops)){stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}}function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>{if(!ptr)return"";var end=findStringEnd(HEAPU8,ptr,maxBytesToRead,ignoreNul);return UTF8Decoder.decode(HEAPU8.subarray(ptr,end))};var SYSCALLS={calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return dir+"/"+path},writeStat(buf,stat){HEAPU32[buf>>2]=stat.dev;HEAPU32[buf+4>>2]=stat.mode;HEAPU32[buf+8>>2]=stat.nlink;HEAPU32[buf+12>>2]=stat.uid;HEAPU32[buf+16>>2]=stat.gid;HEAPU32[buf+20>>2]=stat.rdev;HEAP64[buf+24>>3]=BigInt(stat.size);HEAP32[buf+32>>2]=4096;HEAP32[buf+36>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+40>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;HEAP64[buf+56>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;HEAP64[buf+88>>3]=BigInt(stat.ino);return 0},writeStatFs(buf,stats){HEAPU32[buf+4>>2]=stats.bsize;HEAPU32[buf+60>>2]=stats.bsize;HEAP64[buf+8>>3]=BigInt(stats.blocks);HEAP64[buf+16>>3]=BigInt(stats.bfree);HEAP64[buf+24>>3]=BigInt(stats.bavail);HEAP64[buf+32>>3]=BigInt(stats.files);HEAP64[buf+40>>3]=BigInt(stats.ffree);HEAPU32[buf+48>>2]=stats.fsid;HEAPU32[buf+64>>2]=stats.flags;HEAPU32[buf+56>>2]=stats.namelen},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_fstat64(fd,buf){try{return SYSCALLS.writeStat(buf,FS.fstat(fd))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var startIdx=Math.floor(off/struct_size);var endIdx=Math.min(stream.getdents.length,startIdx+Math.floor(count/struct_size));for(var idx=startIdx;idx>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.lstat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.writeStat(buf,nofollow?FS.lstat(path):FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var syscallGetVarargI=()=>{var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret};function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>abort("");var runtimeKeepaliveCounter=0;var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var timers={};var handleException=e=>{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var maybeExit=()=>{if(!keepRuntimeAlive()){try{_exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{return func()}catch(e){handleException(e)}finally{maybeExit()}};var _emscripten_get_now=()=>performance.now();var __setitimer_js=(which,timeout_ms)=>{if(timers[which]){clearTimeout(timers[which].id);delete timers[which]}if(!timeout_ms)return 0;var id=setTimeout(()=>{delete timers[which];callUserCallback(()=>__emscripten_timeout(which,_emscripten_get_now()))},timeout_ms);timers[which]={id,timeout_ms};return 0};var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;var envp=0;for(var string of getEnvStrings()){var ptr=environ_buf+bufSize;HEAPU32[__environ+envp>>2]=ptr;bufSize+=stringToUTF8(string,ptr,Infinity)+1;envp+=4}return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;for(var string of strings){bufSize+=lengthBytesUTF8(string)+1}HEAPU32[penviron_buf_size>>2]=bufSize;return 0};function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};FS.createPreloadedFile=FS_createPreloadedFile;FS.preloadFile=FS_preloadFile;FS.staticInit();{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["preloadPlugins"])preloadPlugins=Module["preloadPlugins"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _malloc,_free,_mpt_secp256k1_context,_mpt_make_ec_pair,_mpt_serialize_ec_pair,_mpt_get_convert_context_hash,_mpt_get_convert_back_context_hash,_mpt_get_send_context_hash,_mpt_get_clawback_context_hash,_mpt_generate_blinding_factor,_mpt_encrypt_amount,_mpt_decrypt_amount,_mpt_verify_revealed_amount,_mpt_get_convert_proof,_mpt_get_pedersen_commitment,_mpt_get_confidential_send_proof,_mpt_get_convert_back_proof,_mpt_get_clawback_proof,_mpt_verify_convert_proof,_mpt_compute_convert_back_remainder,_mpt_verify_aggregated_bulletproof,_mpt_verify_convert_back_proof,_mpt_verify_send_range_proof,_mpt_verify_send_proof,_mpt_verify_clawback_proof,__emscripten_timeout,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_malloc=Module["_malloc"]=wasmExports["v"];_free=Module["_free"]=wasmExports["w"];_mpt_secp256k1_context=Module["_mpt_secp256k1_context"]=wasmExports["x"];_mpt_make_ec_pair=Module["_mpt_make_ec_pair"]=wasmExports["y"];_mpt_serialize_ec_pair=Module["_mpt_serialize_ec_pair"]=wasmExports["z"];_mpt_get_convert_context_hash=Module["_mpt_get_convert_context_hash"]=wasmExports["A"];_mpt_get_convert_back_context_hash=Module["_mpt_get_convert_back_context_hash"]=wasmExports["B"];_mpt_get_send_context_hash=Module["_mpt_get_send_context_hash"]=wasmExports["C"];_mpt_get_clawback_context_hash=Module["_mpt_get_clawback_context_hash"]=wasmExports["D"];_mpt_generate_blinding_factor=Module["_mpt_generate_blinding_factor"]=wasmExports["E"];_mpt_encrypt_amount=Module["_mpt_encrypt_amount"]=wasmExports["F"];_mpt_decrypt_amount=Module["_mpt_decrypt_amount"]=wasmExports["G"];_mpt_verify_revealed_amount=Module["_mpt_verify_revealed_amount"]=wasmExports["H"];_mpt_get_convert_proof=Module["_mpt_get_convert_proof"]=wasmExports["I"];_mpt_get_pedersen_commitment=Module["_mpt_get_pedersen_commitment"]=wasmExports["J"];_mpt_get_confidential_send_proof=Module["_mpt_get_confidential_send_proof"]=wasmExports["K"];_mpt_get_convert_back_proof=Module["_mpt_get_convert_back_proof"]=wasmExports["L"];_mpt_get_clawback_proof=Module["_mpt_get_clawback_proof"]=wasmExports["M"];_mpt_verify_convert_proof=Module["_mpt_verify_convert_proof"]=wasmExports["N"];_mpt_compute_convert_back_remainder=Module["_mpt_compute_convert_back_remainder"]=wasmExports["O"];_mpt_verify_aggregated_bulletproof=Module["_mpt_verify_aggregated_bulletproof"]=wasmExports["P"];_mpt_verify_convert_back_proof=Module["_mpt_verify_convert_back_proof"]=wasmExports["Q"];_mpt_verify_send_range_proof=Module["_mpt_verify_send_range_proof"]=wasmExports["R"];_mpt_verify_send_proof=Module["_mpt_verify_send_proof"]=wasmExports["S"];_mpt_verify_clawback_proof=Module["_mpt_verify_clawback_proof"]=wasmExports["T"];__emscripten_timeout=wasmExports["U"];__emscripten_stack_restore=wasmExports["V"];__emscripten_stack_alloc=wasmExports["W"];_emscripten_stack_get_current=wasmExports["X"];memory=wasmMemory=wasmExports["t"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={s:___cxa_throw,g:___syscall_fstat64,n:___syscall_getdents64,e:___syscall_lstat64,d:___syscall_newfstatat,p:___syscall_openat,f:___syscall_stat64,r:__abort_js,j:__emscripten_runtime_keepalive_clear,k:__setitimer_js,h:_clock_time_get,b:_emscripten_date_now,l:_emscripten_resize_heap,q:_environ_get,c:_environ_sizes_get,a:_fd_close,o:_fd_read,m:_fd_write,i:_proc_exit};function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +;return moduleRtn}})();if(typeof exports==="object"&&typeof module==="object"){module.exports=MptCrypto;module.exports.default=MptCrypto}else if(typeof define==="function"&&define["amd"])define([],()=>MptCrypto); diff --git a/packages/mpt-crypto/wasm/mpt_crypto.wasm b/packages/mpt-crypto/wasm/mpt_crypto.wasm new file mode 100755 index 0000000000..3c9f189b44 Binary files /dev/null and b/packages/mpt-crypto/wasm/mpt_crypto.wasm differ diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 53c242361e..c6456fcec4 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +* Add definitions for Confidential Transfers for Multi-Purpose Tokens (XLS-96). + ## 2.8.0 (2026-06-04) ### Fixed diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 9aea5ee77c..391ad75d15 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -3468,6 +3468,186 @@ "type": "Validation" } ], + [ + "ConfidentialOutstandingAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 32, + "type": "UInt64" + } + ], + [ + "ConfidentialBalanceVersion", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 69, + "type": "UInt32" + } + ], + [ + "BlindingFactor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 39, + "type": "Hash256" + } + ], + [ + "ConfidentialBalanceInbox", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 32, + "type": "Blob" + } + ], + [ + "ConfidentialBalanceSpending", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 33, + "type": "Blob" + } + ], + [ + "IssuerEncryptedBalance", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 34, + "type": "Blob" + } + ], + [ + "IssuerEncryptionKey", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 35, + "type": "Blob" + } + ], + [ + "HolderEncryptionKey", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 36, + "type": "Blob" + } + ], + [ + "ZKProof", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 37, + "type": "Blob" + } + ], + [ + "HolderEncryptedAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 38, + "type": "Blob" + } + ], + [ + "IssuerEncryptedAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 39, + "type": "Blob" + } + ], + [ + "SenderEncryptedAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 40, + "type": "Blob" + } + ], + [ + "DestinationEncryptedAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 41, + "type": "Blob" + } + ], + [ + "AuditorEncryptedBalance", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 42, + "type": "Blob" + } + ], + [ + "AuditorEncryptedAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 43, + "type": "Blob" + } + ], + [ + "AuditorEncryptionKey", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 44, + "type": "Blob" + } + ], + [ + "AmountCommitment", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 45, + "type": "Blob" + } + ], + [ + "BalanceCommitment", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 46, + "type": "Blob" + } + ], [ "Metadata", { @@ -6410,6 +6590,7 @@ "tecARRAY_EMPTY": 190, "tecARRAY_TOO_LARGE": 191, "tecBAD_CREDENTIALS": 193, + "tecBAD_PROOF": 199, "tecCANT_ACCEPT_OWN_NFTOKEN_OFFER": 158, "tecCLAIM": 100, "tecCRYPTOCONDITION_ERROR": 146, @@ -6526,6 +6707,7 @@ "temARRAY_TOO_LARGE": -252, "temBAD_AMM_TOKENS": -261, "temBAD_AMOUNT": -298, + "temBAD_CIPHERTEXT": -248, "temBAD_CURRENCY": -297, "temBAD_EXPIRATION": -296, "temBAD_FEE": -295, @@ -6606,6 +6788,11 @@ "CheckCash": 17, "CheckCreate": 16, "Clawback": 30, + "ConfidentialMPTClawback": 89, + "ConfidentialMPTConvert": 85, + "ConfidentialMPTConvertBack": 87, + "ConfidentialMPTMergeInbox": 86, + "ConfidentialMPTSend": 88, "CredentialAccept": 59, "CredentialCreate": 58, "CredentialDelete": 60, diff --git a/packages/ripple-binary-codec/test/confidential-mpt.test.ts b/packages/ripple-binary-codec/test/confidential-mpt.test.ts new file mode 100644 index 0000000000..c64164558e --- /dev/null +++ b/packages/ripple-binary-codec/test/confidential-mpt.test.ts @@ -0,0 +1,139 @@ +import { encode, decode } from '../src' + +// Confidential MPT (XLS-0096) canonical field fixtures. +const ACCOUNT = 'r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ' +const DESTINATION = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' +const ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' +// 33-byte compressed EC point (encryption keys, Pedersen commitments). +const EC_POINT = `02${'AB'.repeat(32)}` +// 66-byte ElGamal ciphertext (two compressed points). +const CIPHERTEXT = `02${'AB'.repeat(32)}03${'CD'.repeat(32)}` +// 32-byte scalar blinding factor (Hash256). +const BLINDING = 'AB'.repeat(32) +// Fixed-length zero-knowledge proofs. +const SEND_PROOF = 'AB'.repeat(946) +const CONVERT_BACK_PROOF = 'AB'.repeat(816) +const SCHNORR_PROOF = 'AB'.repeat(64) +const CREDENTIAL_ID = + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A' + +/** + * Assert that an object survives an `encode` → `decode` round-trip unchanged. + * + * The re-encode equality is representation-agnostic (proves encode/decode are + * consistent inverses); the `toEqual` proves no field is silently dropped or + * altered, given the inputs are already in canonical decoded form. + * + * @param obj - The transaction or ledger-entry object to round-trip. + */ +function assertRoundTrip(obj: Record): void { + const encoded = encode(obj) + const decoded = decode(encoded) + expect(encode(decoded)).toBe(encoded) + expect(decoded).toEqual(obj) +} + +describe('Confidential MPT (XLS-0096) binary codec', function () { + it('round-trips ConfidentialMPTConvert (all fields)', function () { + assertRoundTrip({ + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + Sequence: 1, + MPTokenIssuanceID: ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptionKey: EC_POINT, + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: SCHNORR_PROOF, + }) + }) + + it('round-trips ConfidentialMPTConvertBack (all fields)', function () { + assertRoundTrip({ + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + Sequence: 2, + MPTokenIssuanceID: ISSUANCE_ID, + MPTAmount: '250', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: CONVERT_BACK_PROOF, + BalanceCommitment: EC_POINT, + }) + }) + + it('round-trips ConfidentialMPTSend (all fields)', function () { + assertRoundTrip({ + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + Sequence: 3, + MPTokenIssuanceID: ISSUANCE_ID, + Destination: DESTINATION, + DestinationTag: 12345, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + ZKProof: SEND_PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + CredentialIDs: [CREDENTIAL_ID], + }) + }) + + it('round-trips ConfidentialMPTClawback (all fields)', function () { + assertRoundTrip({ + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + Sequence: 4, + MPTokenIssuanceID: ISSUANCE_ID, + Holder: DESTINATION, + MPTAmount: '100', + ZKProof: SCHNORR_PROOF, + }) + }) + + it('round-trips ConfidentialMPTMergeInbox', function () { + assertRoundTrip({ + TransactionType: 'ConfidentialMPTMergeInbox', + Account: ACCOUNT, + Sequence: 5, + MPTokenIssuanceID: ISSUANCE_ID, + }) + }) + + it('round-trips an MPToken ledger entry with confidential fields', function () { + assertRoundTrip({ + LedgerEntryType: 'MPToken', + ConfidentialBalanceVersion: 7, + ConfidentialBalanceInbox: CIPHERTEXT, + ConfidentialBalanceSpending: CIPHERTEXT, + IssuerEncryptedBalance: CIPHERTEXT, + AuditorEncryptedBalance: CIPHERTEXT, + HolderEncryptionKey: EC_POINT, + }) + }) + + it('round-trips an MPTokenIssuance ledger entry with confidential fields', function () { + assertRoundTrip({ + LedgerEntryType: 'MPTokenIssuance', + // Generic UInt64 fields round-trip in canonical 16-char hex form. + ConfidentialOutstandingAmount: '0000000000012345', + IssuerEncryptionKey: EC_POINT, + AuditorEncryptionKey: EC_POINT, + }) + }) + + it('decodes tecBAD_PROOF in transaction metadata', function () { + const meta = { + TransactionResult: 'tecBAD_PROOF', + TransactionIndex: 0, + AffectedNodes: [], + } + expect(decode(encode(meta)).TransactionResult).toBe('tecBAD_PROOF') + }) +}) diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 5abf7e0df8..2d698caac2 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -15,6 +15,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr * `Client.getServerInfo()` and `Client.connect()` now throw if the `server_info` request fails, or if the response succeeds but does not include a `network_id`. Previously, these failures were swallowed and only logged via `console.error`, leaving `client.networkID` undefined and causing `autofill()` to omit the `NetworkID` field — producing transactions valid on the wrong network. Servers running rippled <1.11 (which do not return `network_id`) will now fail to connect; upgrade to rippled 1.11+ or set `client.networkID` manually after construction. ### Added +* Add support for Confidential Transfers for Multi-Purpose Tokens (XLS-96). * Add new fields to `ServerDefinitionsResponse`: `ACCOUNT_SET_FLAGS`, `LEDGER_ENTRY_FLAGS`, `LEDGER_ENTRY_FORMATS`, `TRANSACTION_FLAGS`, and `TRANSACTION_FORMATS`, reflecting new sections returned by `server_definitions` in rippled. ### Fixed diff --git a/packages/xrpl/package.json b/packages/xrpl/package.json index ee49748175..1211f2b286 100644 --- a/packages/xrpl/package.json +++ b/packages/xrpl/package.json @@ -15,6 +15,17 @@ "unpkg": "build/xrpl-latest-min.js", "jsdelivr": "build/xrpl-latest-min.js", "types": "dist/npm/index.d.ts", + "exports": { + ".": { + "types": "./dist/npm/index.d.ts", + "default": "./dist/npm/index.js" + }, + "./confidential": { + "types": "./dist/npm/confidential/index.d.ts", + "default": "./dist/npm/confidential/index.js" + }, + "./dist/npm/*": "./dist/npm/*" + }, "directories": { "test": "test" }, @@ -33,6 +44,14 @@ "ripple-binary-codec": "^2.7.0", "ripple-keypairs": "^2.0.0" }, + "peerDependencies": { + "@xrplf/mpt-crypto": "^0.1.0" + }, + "peerDependenciesMeta": { + "@xrplf/mpt-crypto": { + "optional": true + } + }, "devDependencies": { "@types/node": "^18.18.38", "eventemitter3": "^5.0.1", diff --git a/packages/xrpl/src/confidential/convert.ts b/packages/xrpl/src/confidential/convert.ts new file mode 100644 index 0000000000..e4720d7cc9 --- /dev/null +++ b/packages/xrpl/src/confidential/convert.ts @@ -0,0 +1,207 @@ +import { type Client } from '../client' +import { XrplError } from '../errors' +import { + ConfidentialMPTConvert, + ConfidentialMPTConvertBack, + ConfidentialMPTMergeInbox, +} from '../models/transactions' + +import { + accountIdHex, + fetchMPToken, + fetchMPTokenIssuance, + resolveSequence, +} from './ledger' +import { loadMptCrypto } from './loader' +import { + ConfidentialConvertBackParams, + ConfidentialConvertParams, + ConfidentialMergeInboxParams, +} from './types' + +/** + * Build a ConfidentialMPTConvert transaction that moves a holder's public MPT + * balance into their confidential balance. The amount is encrypted under the + * holder, issuer, and (when registered) auditor keys with a shared blinding + * factor, and a Schnorr proof attests ownership of the holder key. + * + * @param client - A connected Client. + * @param params - The conversion inputs. + * @returns The assembled, unsigned ConfidentialMPTConvert transaction. + * @throws {XrplError} If the issuer encryption key is not registered. + */ +// eslint-disable-next-line max-lines-per-function -- one cohesive proof-assembly flow +export async function prepareConfidentialConvert( + client: Client, + params: ConfidentialConvertParams, +): Promise { + const [crypto, issuance, sequence] = await Promise.all([ + loadMptCrypto(), + fetchMPTokenIssuance(client, params.mptIssuanceID), + resolveSequence(client, params.account, params.sequence), + ]) + if (issuance.IssuerEncryptionKey == null) { + throw new XrplError( + `Issuance ${params.mptIssuanceID} has no registered IssuerEncryptionKey`, + ) + } + const { amount, holder } = params + + const [blindingFactor, contextHash] = await Promise.all([ + crypto.generateBlindingFactor(), + crypto.getConvertContextHash( + accountIdHex(params.account), + params.mptIssuanceID, + sequence, + ), + ]) + const [holderEncryptedAmount, issuerEncryptedAmount, zkProof] = + await Promise.all([ + crypto.encryptAmount(amount, holder.publicKey, blindingFactor), + crypto.encryptAmount( + amount, + issuance.IssuerEncryptionKey, + blindingFactor, + ), + crypto.getConvertProof(holder.publicKey, holder.privateKey, contextHash), + ]) + + const tx: ConfidentialMPTConvert = { + TransactionType: 'ConfidentialMPTConvert', + Account: params.account, + Sequence: sequence, + MPTokenIssuanceID: params.mptIssuanceID, + MPTAmount: amount.toString(), + HolderEncryptedAmount: holderEncryptedAmount, + IssuerEncryptedAmount: issuerEncryptedAmount, + BlindingFactor: blindingFactor, + ZKProof: zkProof, + } + if (issuance.AuditorEncryptionKey != null) { + tx.AuditorEncryptedAmount = await crypto.encryptAmount( + amount, + issuance.AuditorEncryptionKey, + blindingFactor, + ) + } + if (params.registerKey ?? true) { + tx.HolderEncryptionKey = holder.publicKey + } + return tx +} + +/** + * Build a ConfidentialMPTConvertBack transaction that reveals a public MPT + * amount from a holder's confidential balance. The holder's spendable balance is + * decrypted to form the Pedersen balance witness bound by the range proof. + * + * @param client - A connected Client. + * @param params - The convert-back inputs. + * @returns The assembled, unsigned ConfidentialMPTConvertBack transaction. + * @throws {XrplError} If the issuer key or the holder's spendable balance is missing. + */ +// eslint-disable-next-line max-lines-per-function -- one cohesive proof-assembly flow +export async function prepareConfidentialConvertBack( + client: Client, + params: ConfidentialConvertBackParams, +): Promise { + const [crypto, issuance, mptoken, sequence] = await Promise.all([ + loadMptCrypto(), + fetchMPTokenIssuance(client, params.mptIssuanceID), + fetchMPToken(client, params.account, params.mptIssuanceID), + resolveSequence(client, params.account, params.sequence), + ]) + if (issuance.IssuerEncryptionKey == null) { + throw new XrplError( + `Issuance ${params.mptIssuanceID} has no registered IssuerEncryptionKey`, + ) + } + if (mptoken.ConfidentialBalanceSpending == null) { + throw new XrplError( + `Account ${params.account} has no confidential spending balance`, + ) + } + const { amount, holder } = params + const spending = mptoken.ConfidentialBalanceSpending + const version = mptoken.ConfidentialBalanceVersion ?? 0 + + // `balance` is the full current balance (the range-proof witness); `rho` + // blinds the balance commitment, `blindingFactor` the revealed-amount + // ciphertexts. The proof links the on-ledger `spending` ciphertext via the + // holder's private key. + const [balance, blindingFactor, rho, contextHash] = await Promise.all([ + crypto.decryptAmount(spending, holder.privateKey), + crypto.generateBlindingFactor(), + crypto.generateBlindingFactor(), + crypto.getConvertBackContextHash( + accountIdHex(params.account), + params.mptIssuanceID, + sequence, + version, + ), + ]) + const balanceCommitment = await crypto.getPedersenCommitment(balance, rho) + const [holderEncryptedAmount, issuerEncryptedAmount, zkProof] = + await Promise.all([ + crypto.encryptAmount(amount, holder.publicKey, blindingFactor), + crypto.encryptAmount( + amount, + issuance.IssuerEncryptionKey, + blindingFactor, + ), + crypto.getConvertBackProof( + holder.privateKey, + holder.publicKey, + contextHash, + amount, + { + commitment: balanceCommitment, + amount: balance, + ciphertext: spending, + blindingFactor: rho, + }, + ), + ]) + + const tx: ConfidentialMPTConvertBack = { + TransactionType: 'ConfidentialMPTConvertBack', + Account: params.account, + Sequence: sequence, + MPTokenIssuanceID: params.mptIssuanceID, + MPTAmount: amount.toString(), + HolderEncryptedAmount: holderEncryptedAmount, + IssuerEncryptedAmount: issuerEncryptedAmount, + BlindingFactor: blindingFactor, + BalanceCommitment: balanceCommitment, + ZKProof: zkProof, + } + if (issuance.AuditorEncryptionKey != null) { + tx.AuditorEncryptedAmount = await crypto.encryptAmount( + amount, + issuance.AuditorEncryptionKey, + blindingFactor, + ) + } + return tx +} + +/** + * Build a ConfidentialMPTMergeInbox transaction that folds a holder's pending + * confidential inbox balance into their spendable balance. No crypto material is + * required; the builder only resolves the account sequence. + * + * @param client - A connected Client. + * @param params - The merge-inbox inputs. + * @returns The assembled, unsigned ConfidentialMPTMergeInbox transaction. + */ +export async function prepareConfidentialMergeInbox( + client: Client, + params: ConfidentialMergeInboxParams, +): Promise { + return { + TransactionType: 'ConfidentialMPTMergeInbox', + Account: params.account, + Sequence: await resolveSequence(client, params.account, params.sequence), + MPTokenIssuanceID: params.mptIssuanceID, + } +} diff --git a/packages/xrpl/src/confidential/index.ts b/packages/xrpl/src/confidential/index.ts new file mode 100644 index 0000000000..8573ac096a --- /dev/null +++ b/packages/xrpl/src/confidential/index.ts @@ -0,0 +1,42 @@ +/** + * `xrpl/confidential` — optional, lazily-loaded integration layer for + * Confidential MPT (XLS-0096). High-level builders assemble each confidential + * transaction (querying ledger state, generating shared-blinding ciphertexts, + * commitments, and ordered zero-knowledge proofs) so callers never hand-build + * the cryptographic material. + * + * The crypto lives in the optional `@xrplf/mpt-crypto` peer dependency, reached + * only through a dynamic import. Nothing here is exported from `xrpl`'s main + * entry point, so users who don't need confidential MPT install nothing extra. + */ + +export { loadMptCrypto } from './loader' +export type { MptCryptoModule } from './loader' + +export { + accountIdHex, + fetchMPToken, + fetchMPTokenIssuance, + getAccountSequence, + getConfidentialBalance, +} from './ledger' + +export { + prepareConfidentialConvert, + prepareConfidentialConvertBack, + prepareConfidentialMergeInbox, +} from './convert' + +export { + prepareConfidentialClawback, + prepareConfidentialSend, +} from './transfer' + +export type { + ConfidentialClawbackParams, + ConfidentialConvertBackParams, + ConfidentialConvertParams, + ConfidentialKeypair, + ConfidentialMergeInboxParams, + ConfidentialSendParams, +} from './types' diff --git a/packages/xrpl/src/confidential/ledger.ts b/packages/xrpl/src/confidential/ledger.ts new file mode 100644 index 0000000000..00ae669ca3 --- /dev/null +++ b/packages/xrpl/src/confidential/ledger.ts @@ -0,0 +1,121 @@ +import { bytesToHex } from '@xrplf/isomorphic/utils' +import { decodeAccountID } from 'ripple-address-codec' + +import { type Client } from '../client' +import { MPToken, MPTokenIssuance } from '../models/ledger' + +import { loadMptCrypto } from './loader' + +/** + * Convert a classic XRPL address to its 20-byte AccountID as uppercase hex, + * the form the `@xrplf/mpt-crypto` context-hash functions expect. + * + * @param account - The classic XRPL address (`r...`). + * @returns The 20-byte AccountID encoded as uppercase hex. + */ +export function accountIdHex(account: string): string { + return bytesToHex(decodeAccountID(account)) +} + +/** + * Fetch the next sequence number for an account from the current ledger. + * + * @param client - A connected Client. + * @param account - The classic XRPL address whose sequence is requested. + * @returns The account's current `Sequence`. + */ +export async function getAccountSequence( + client: Client, + account: string, +): Promise { + const response = await client.request({ + command: 'account_info', + account, + }) + return response.result.account_data.Sequence +} + +/** + * Resolve the sequence to bind a confidential transaction to: the caller's + * explicit value when given, otherwise the account's current sequence. The + * builders pin this so the proof's context hash matches the submitted tx. + * + * @param client - A connected Client. + * @param account - The classic XRPL address whose sequence is used as fallback. + * @param sequence - An explicit sequence, or `undefined` to query the ledger. + * @returns The resolved sequence number. + */ +export async function resolveSequence( + client: Client, + account: string, + sequence?: number, +): Promise { + return sequence ?? (await getAccountSequence(client, account)) +} + +/** + * Fetch a single MPToken ledger object for a (holder, issuance) pair. + * + * @param client - A connected Client. + * @param account - The classic XRPL address of the token holder. + * @param mptIssuanceID - The 24-byte hex MPTokenIssuanceID. + * @returns The holder's MPToken ledger entry. + * @throws {RippledError} If the MPToken does not exist. + */ +export async function fetchMPToken( + client: Client, + account: string, + mptIssuanceID: string, +): Promise { + const response = await client.request({ + command: 'ledger_entry', + mptoken: { mpt_issuance_id: mptIssuanceID, account }, + }) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ledger_entry returns the requested entry type + return response.result.node as unknown as MPToken +} + +/** + * Fetch the MPTokenIssuance ledger object, which carries the registered issuer + * and (optional) auditor encryption keys. + * + * @param client - A connected Client. + * @param mptIssuanceID - The 24-byte hex MPTokenIssuanceID. + * @returns The MPTokenIssuance ledger entry. + * @throws {RippledError} If the MPTokenIssuance does not exist. + */ +export async function fetchMPTokenIssuance( + client: Client, + mptIssuanceID: string, +): Promise { + const response = await client.request({ + command: 'ledger_entry', + mpt_issuance: mptIssuanceID, + }) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ledger_entry returns the requested entry type + return response.result.node as unknown as MPTokenIssuance +} + +/** + * Decrypt a holder's spendable confidential balance from the ledger. + * + * @param client - A connected Client. + * @param account - The classic XRPL address of the token holder. + * @param mptIssuanceID - The 24-byte hex MPTokenIssuanceID. + * @param privateKey - The holder's 32-byte hex ElGamal private key. + * @returns The decrypted spendable balance, or `0n` if none is set. + */ +// eslint-disable-next-line max-params -- a connected client plus the (account, issuance, key) lookup tuple +export async function getConfidentialBalance( + client: Client, + account: string, + mptIssuanceID: string, + privateKey: string, +): Promise { + const mptoken = await fetchMPToken(client, account, mptIssuanceID) + if (mptoken.ConfidentialBalanceSpending == null) { + return BigInt(0) + } + const crypto = await loadMptCrypto() + return crypto.decryptAmount(mptoken.ConfidentialBalanceSpending, privateKey) +} diff --git a/packages/xrpl/src/confidential/loader.ts b/packages/xrpl/src/confidential/loader.ts new file mode 100644 index 0000000000..551caa30d6 --- /dev/null +++ b/packages/xrpl/src/confidential/loader.ts @@ -0,0 +1,35 @@ +import { XrplError } from '../errors' + +/** + * The shape of the lazily-loaded `@xrplf/mpt-crypto` module. Resolved from the + * package's own type declarations so the integration layer stays in sync with + * the crypto contract without bundling it into `xrpl`'s main entry point. + */ +export type MptCryptoModule = typeof import('@xrplf/mpt-crypto') + +let cached: Promise | undefined + +/** + * Lazily import the optional `@xrplf/mpt-crypto` peer dependency, caching the + * resolved module so the WASM binary is only loaded once. Confidential MPT + * operations are the sole consumers of this loader, so users who never touch + * `xrpl/confidential` never pay the dependency or load cost. + * + * @returns The resolved `@xrplf/mpt-crypto` module. + * @throws {XrplError} If the optional peer dependency is not installed. + */ +export async function loadMptCrypto(): Promise { + /* eslint-disable no-inline-comments -- the webpack chunk-name hint must lead the import specifier */ + cached ??= import( + /* webpackChunkName: "mpt-crypto" */ '@xrplf/mpt-crypto' + ).catch((error: unknown) => { + cached = undefined + throw new XrplError( + 'Confidential MPT operations require the optional "@xrplf/mpt-crypto" ' + + 'package. Install it with `npm install @xrplf/mpt-crypto`.', + error, + ) + }) + /* eslint-enable no-inline-comments */ + return cached +} diff --git a/packages/xrpl/src/confidential/transfer.ts b/packages/xrpl/src/confidential/transfer.ts new file mode 100644 index 0000000000..86af26433a --- /dev/null +++ b/packages/xrpl/src/confidential/transfer.ts @@ -0,0 +1,203 @@ +import { type Client } from '../client' +import { XrplError } from '../errors' +import { + ConfidentialMPTClawback, + ConfidentialMPTSend, +} from '../models/transactions' + +import { + accountIdHex, + fetchMPToken, + fetchMPTokenIssuance, + resolveSequence, +} from './ledger' +import { loadMptCrypto } from './loader' +import { ConfidentialClawbackParams, ConfidentialSendParams } from './types' + +interface SendParticipant { + publicKey: string + ciphertext: string +} + +/** + * Build a ConfidentialMPTSend transaction that transfers a confidential amount + * from the sender's spendable balance to the destination's inbox. The amount is + * encrypted under the sender, destination, issuer, and (when registered) auditor + * keys with a shared blinding factor, and a single proof binds the amount + * commitment, balance commitment, and per-recipient ciphertexts. + * + * @param client - A connected Client. + * @param params - The send inputs. + * @returns The assembled, unsigned ConfidentialMPTSend transaction. + * @throws {XrplError} If a required encryption key or the sender balance is missing. + */ +// eslint-disable-next-line max-lines-per-function, max-statements -- one cohesive proof-assembly flow +export async function prepareConfidentialSend( + client: Client, + params: ConfidentialSendParams, +): Promise { + const [crypto, issuance, senderToken, destToken, sequence] = + await Promise.all([ + loadMptCrypto(), + fetchMPTokenIssuance(client, params.mptIssuanceID), + fetchMPToken(client, params.account, params.mptIssuanceID), + fetchMPToken(client, params.destination, params.mptIssuanceID), + resolveSequence(client, params.account, params.sequence), + ]) + if (issuance.IssuerEncryptionKey == null) { + throw new XrplError( + `Issuance ${params.mptIssuanceID} has no registered IssuerEncryptionKey`, + ) + } + if (senderToken.ConfidentialBalanceSpending == null) { + throw new XrplError( + `Account ${params.account} has no confidential spending balance`, + ) + } + if (destToken.HolderEncryptionKey == null) { + throw new XrplError( + `Destination ${params.destination} has no registered HolderEncryptionKey`, + ) + } + const { amount, sender } = params + const destKey = destToken.HolderEncryptionKey + const issuerKey = issuance.IssuerEncryptionKey + const spending = senderToken.ConfidentialBalanceSpending + const version = senderToken.ConfidentialBalanceVersion ?? 0 + + // `txBlinding` is the shared ElGamal randomness AND the amount-commitment + // blinding; `rho` blinds the balance commitment. `balance` is the sender's + // full current balance, the range-proof witness linked to the on-ledger + // `spending` ciphertext via the sender's private key. + const [balance, txBlinding, rho, contextHash] = await Promise.all([ + crypto.decryptAmount(spending, sender.privateKey), + crypto.generateBlindingFactor(), + crypto.generateBlindingFactor(), + crypto.getSendContextHash( + accountIdHex(params.account), + params.mptIssuanceID, + sequence, + accountIdHex(params.destination), + version, + ), + ]) + const [amountCommitment, balanceCommitment, senderCt, destCt, issuerCt] = + await Promise.all([ + crypto.getPedersenCommitment(amount, txBlinding), + crypto.getPedersenCommitment(balance, rho), + crypto.encryptAmount(amount, sender.publicKey, txBlinding), + crypto.encryptAmount(amount, destKey, txBlinding), + crypto.encryptAmount(amount, issuerKey, txBlinding), + ]) + + // Proof participants are ordered sender, destination, issuer, [auditor]. + const participants: SendParticipant[] = [ + { publicKey: sender.publicKey, ciphertext: senderCt }, + { publicKey: destKey, ciphertext: destCt }, + { publicKey: issuerKey, ciphertext: issuerCt }, + ] + let auditorCt: string | undefined + if (issuance.AuditorEncryptionKey != null) { + auditorCt = await crypto.encryptAmount( + amount, + issuance.AuditorEncryptionKey, + txBlinding, + ) + participants.push({ + publicKey: issuance.AuditorEncryptionKey, + ciphertext: auditorCt, + }) + } + + const tx: ConfidentialMPTSend = { + TransactionType: 'ConfidentialMPTSend', + Account: params.account, + Sequence: sequence, + MPTokenIssuanceID: params.mptIssuanceID, + Destination: params.destination, + SenderEncryptedAmount: senderCt, + DestinationEncryptedAmount: destCt, + IssuerEncryptedAmount: issuerCt, + AmountCommitment: amountCommitment, + BalanceCommitment: balanceCommitment, + ZKProof: await crypto.getConfidentialSendProof({ + privateKey: sender.privateKey, + publicKey: sender.publicKey, + amount, + participants, + txBlindingFactor: txBlinding, + contextHash, + amountCommitment, + balanceParams: { + commitment: balanceCommitment, + amount: balance, + ciphertext: spending, + blindingFactor: rho, + }, + }), + } + if (auditorCt != null) { + tx.AuditorEncryptedAmount = auditorCt + } + if (params.destinationTag != null) { + tx.DestinationTag = params.destinationTag + } + if (params.credentialIDs != null) { + tx.CredentialIDs = params.credentialIDs + } + return tx +} + +/** + * Build a ConfidentialMPTClawback transaction. The issuer recovers the clawed + * amount by decrypting the holder's issuer-encrypted balance (unless an explicit + * amount is supplied) and attaches a proof over that ciphertext. + * + * @param client - A connected Client. + * @param params - The clawback inputs. + * @returns The assembled, unsigned ConfidentialMPTClawback transaction. + * @throws {XrplError} If the holder has no issuer-encrypted balance. + */ +// eslint-disable-next-line max-lines-per-function -- one cohesive proof-assembly flow +export async function prepareConfidentialClawback( + client: Client, + params: ConfidentialClawbackParams, +): Promise { + const [crypto, holderToken, sequence] = await Promise.all([ + loadMptCrypto(), + fetchMPToken(client, params.holder, params.mptIssuanceID), + resolveSequence(client, params.account, params.sequence), + ]) + if (holderToken.IssuerEncryptedBalance == null) { + throw new XrplError( + `Holder ${params.holder} has no issuer-encrypted confidential balance`, + ) + } + const { issuer } = params + const issuerBalance = holderToken.IssuerEncryptedBalance + const amount = + params.amount ?? + (await crypto.decryptAmount(issuerBalance, issuer.privateKey)) + const contextHash = await crypto.getClawbackContextHash( + accountIdHex(params.account), + params.mptIssuanceID, + sequence, + accountIdHex(params.holder), + ) + + return { + TransactionType: 'ConfidentialMPTClawback', + Account: params.account, + Sequence: sequence, + MPTokenIssuanceID: params.mptIssuanceID, + Holder: params.holder, + MPTAmount: amount.toString(), + ZKProof: await crypto.getClawbackProof( + issuer.privateKey, + issuer.publicKey, + contextHash, + amount, + issuerBalance, + ), + } +} diff --git a/packages/xrpl/src/confidential/types.ts b/packages/xrpl/src/confidential/types.ts new file mode 100644 index 0000000000..49c767adc1 --- /dev/null +++ b/packages/xrpl/src/confidential/types.ts @@ -0,0 +1,88 @@ +/** + * Parameter types for the high-level Confidential MPT (XLS-0096) builders. Every + * byte field is an uppercase, even-length hex string (no `0x` prefix); integer + * amounts are `bigint` to losslessly carry the full `uint64_t` range. + */ + +/** + * An ElGamal keypair used to encrypt to, and decrypt from, a confidential MPT + * balance: a 32-byte hex private key and the matching 33-byte hex public key. + */ +export interface ConfidentialKeypair { + privateKey: string + publicKey: string +} + +/** Inputs shared by every confidential builder. */ +interface BaseConfidentialParams { + /** The 24-byte hex MPTokenIssuanceID. */ + mptIssuanceID: string + /** + * Optional explicit sequence number. When omitted the builder queries the + * account's current sequence. The returned transaction pins `Sequence`, so it + * must be submitted without re-deriving the sequence (the proof is bound to it). + */ + sequence?: number +} + +/** Inputs for {@link prepareConfidentialConvert}. */ +export interface ConfidentialConvertParams extends BaseConfidentialParams { + /** The converting holder's classic XRPL address. */ + account: string + /** The public MPT amount being moved into the confidential balance. */ + amount: bigint + /** The holder's ElGamal keypair. */ + holder: ConfidentialKeypair + /** + * Whether to register the holder's encryption key on this transaction. + * Defaults to `true` (required on a holder's first conversion). + */ + registerKey?: boolean +} + +/** Inputs for {@link prepareConfidentialConvertBack}. */ +export interface ConfidentialConvertBackParams extends BaseConfidentialParams { + /** The holder's classic XRPL address. */ + account: string + /** The public MPT amount being revealed from the confidential balance. */ + amount: bigint + /** The holder's ElGamal keypair. */ + holder: ConfidentialKeypair +} + +/** Inputs for {@link prepareConfidentialSend}. */ +export interface ConfidentialSendParams extends BaseConfidentialParams { + /** The sender's classic XRPL address. */ + account: string + /** The destination's classic XRPL address. */ + destination: string + /** The confidential MPT amount being transferred. */ + amount: bigint + /** The sender's ElGamal keypair. */ + sender: ConfidentialKeypair + /** Optional destination tag. */ + destinationTag?: number + /** Optional credential IDs to satisfy the destination's deposit auth. */ + credentialIDs?: string[] +} + +/** Inputs for {@link prepareConfidentialClawback}. */ +export interface ConfidentialClawbackParams extends BaseConfidentialParams { + /** The issuer's classic XRPL address. */ + account: string + /** The holder whose confidential balance is being clawed back. */ + holder: string + /** The issuer's ElGamal keypair. */ + issuer: ConfidentialKeypair + /** + * Optional explicit amount to claw back. When omitted the builder decrypts the + * holder's issuer-encrypted balance to recover the full amount. + */ + amount?: bigint +} + +/** Inputs for {@link prepareConfidentialMergeInbox}. */ +export interface ConfidentialMergeInboxParams extends BaseConfidentialParams { + /** The holder's classic XRPL address. */ + account: string +} diff --git a/packages/xrpl/src/models/ledger/MPToken.ts b/packages/xrpl/src/models/ledger/MPToken.ts index 6f4e663ede..9cf0931ff1 100644 --- a/packages/xrpl/src/models/ledger/MPToken.ts +++ b/packages/xrpl/src/models/ledger/MPToken.ts @@ -7,4 +7,16 @@ export interface MPToken extends BaseLedgerEntry, HasPreviousTxnID { Flags: number OwnerNode?: string LockedAmount?: string + /** ElGamal ciphertext of the holder's pending confidential inbox balance. */ + ConfidentialBalanceInbox?: string + /** ElGamal ciphertext of the holder's spendable confidential balance. */ + ConfidentialBalanceSpending?: string + /** Version counter for the holder's confidential balance state. */ + ConfidentialBalanceVersion?: number + /** ElGamal ciphertext of the holder's confidential balance under the issuer's key. */ + IssuerEncryptedBalance?: string + /** ElGamal ciphertext of the holder's confidential balance under the auditor's key. */ + AuditorEncryptedBalance?: string + /** The holder's registered compressed ElGamal encryption key. */ + HolderEncryptionKey?: string } diff --git a/packages/xrpl/src/models/ledger/MPTokenIssuance.ts b/packages/xrpl/src/models/ledger/MPTokenIssuance.ts index b590071478..bf001cb711 100644 --- a/packages/xrpl/src/models/ledger/MPTokenIssuance.ts +++ b/packages/xrpl/src/models/ledger/MPTokenIssuance.ts @@ -11,4 +11,10 @@ export interface MPTokenIssuance extends BaseLedgerEntry, HasPreviousTxnID { MPTokenMetadata?: string OwnerNode?: string LockedAmount?: string + /** The issuer's registered compressed ElGamal encryption key. */ + IssuerEncryptionKey?: string + /** The auditor's registered compressed ElGamal encryption key. */ + AuditorEncryptionKey?: string + /** The total confidential (encrypted) outstanding amount for this issuance. */ + ConfidentialOutstandingAmount?: string } diff --git a/packages/xrpl/src/models/transactions/ConfidentialMPTClawback.ts b/packages/xrpl/src/models/transactions/ConfidentialMPTClawback.ts new file mode 100644 index 0000000000..ab456a77ea --- /dev/null +++ b/packages/xrpl/src/models/transactions/ConfidentialMPTClawback.ts @@ -0,0 +1,50 @@ +import { + Account, + BaseTransaction, + isAccount, + isHexBlob, + isString, + validateBaseTransaction, + validateRequiredField, +} from './common' + +/** + * The ConfidentialMPTClawback transaction lets an issuer claw back a confidential + * MPT amount from a holder's confidential balance. + * + * @category Transaction Models + */ +export interface ConfidentialMPTClawback extends BaseTransaction { + TransactionType: 'ConfidentialMPTClawback' + /** + * Identifies the MPTokenIssuance being clawed back. + */ + MPTokenIssuanceID: string + /** The XRPL Address of the holder whose confidential balance is clawed back. */ + Holder: Account + /** + * The MPT amount being clawed back from the holder. + */ + MPTAmount: string + /** + * The zero-knowledge proof authorizing the clawback against the holder's + * confidential balance. + */ + ZKProof: string +} + +/** + * Verify the form and type of a ConfidentialMPTClawback at runtime. + * + * @param tx - A ConfidentialMPTClawback Transaction. + * @throws When the ConfidentialMPTClawback is malformed. + */ +export function validateConfidentialMPTClawback( + tx: Record, +): void { + validateBaseTransaction(tx) + validateRequiredField(tx, 'MPTokenIssuanceID', isString) + validateRequiredField(tx, 'Holder', isAccount) + validateRequiredField(tx, 'MPTAmount', isString) + validateRequiredField(tx, 'ZKProof', isHexBlob) +} diff --git a/packages/xrpl/src/models/transactions/ConfidentialMPTConvert.ts b/packages/xrpl/src/models/transactions/ConfidentialMPTConvert.ts new file mode 100644 index 0000000000..e7390a1e3e --- /dev/null +++ b/packages/xrpl/src/models/transactions/ConfidentialMPTConvert.ts @@ -0,0 +1,99 @@ +import { + BaseTransaction, + isString, + isHexBlob, + isHexWithByteLength, + validateBaseTransaction, + validateRequiredField, + validateOptionalField, + CONFIDENTIAL_EC_POINT_BYTES, + CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES, + CONFIDENTIAL_BLINDING_FACTOR_BYTES, +} from './common' + +/** + * The ConfidentialMPTConvert transaction moves a holder's public MPT balance + * into their confidential (encrypted) balance. It is also used by a holder to + * register their ElGamal encryption key for the issuance. + * + * @category Transaction Models + */ +export interface ConfidentialMPTConvert extends BaseTransaction { + TransactionType: 'ConfidentialMPTConvert' + /** + * Identifies the MPTokenIssuance whose balance is being converted. + */ + MPTokenIssuanceID: string + /** + * The public MPT amount being converted into the confidential balance. + */ + MPTAmount: string + /** + * The holder's compressed ElGamal encryption key (33-byte EC point). Supplied + * when the holder registers their encryption key for this issuance. + */ + HolderEncryptionKey?: string + /** + * ElGamal ciphertext of the amount encrypted under the holder's key + * (66 bytes). + */ + HolderEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the issuer's key + * (66 bytes). + */ + IssuerEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the auditor's key + * (66 bytes). Present only when an auditor key is registered. + */ + AuditorEncryptedAmount?: string + /** + * The scalar blinding factor (32 bytes) shared across the ciphertexts. + */ + BlindingFactor: string + /** + * The zero-knowledge proof binding the ciphertexts to the public amount. + */ + ZKProof?: string +} + +/** + * Verify the form and type of a ConfidentialMPTConvert at runtime. + * + * @param tx - A ConfidentialMPTConvert Transaction. + * @throws When the ConfidentialMPTConvert is malformed. + */ +export function validateConfidentialMPTConvert( + tx: Record, +): void { + validateBaseTransaction(tx) + validateRequiredField(tx, 'MPTokenIssuanceID', isString) + validateRequiredField(tx, 'MPTAmount', isString) + validateOptionalField( + tx, + 'HolderEncryptionKey', + isHexWithByteLength(CONFIDENTIAL_EC_POINT_BYTES), + ) + validateRequiredField( + tx, + 'HolderEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateRequiredField( + tx, + 'IssuerEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateOptionalField( + tx, + 'AuditorEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateRequiredField( + tx, + 'BlindingFactor', + isHexWithByteLength(CONFIDENTIAL_BLINDING_FACTOR_BYTES), + ) + validateOptionalField(tx, 'ZKProof', isHexBlob) +} diff --git a/packages/xrpl/src/models/transactions/ConfidentialMPTConvertBack.ts b/packages/xrpl/src/models/transactions/ConfidentialMPTConvertBack.ts new file mode 100644 index 0000000000..3eae911ec1 --- /dev/null +++ b/packages/xrpl/src/models/transactions/ConfidentialMPTConvertBack.ts @@ -0,0 +1,99 @@ +import { + BaseTransaction, + isString, + isHexBlob, + isHexWithByteLength, + validateBaseTransaction, + validateRequiredField, + validateOptionalField, + CONFIDENTIAL_EC_POINT_BYTES, + CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES, + CONFIDENTIAL_BLINDING_FACTOR_BYTES, +} from './common' + +/** + * The ConfidentialMPTConvertBack transaction moves a holder's confidential + * (encrypted) balance back into their public MPT balance. + * + * @category Transaction Models + */ +export interface ConfidentialMPTConvertBack extends BaseTransaction { + TransactionType: 'ConfidentialMPTConvertBack' + /** + * Identifies the MPTokenIssuance whose balance is being converted back. + */ + MPTokenIssuanceID: string + /** + * The public MPT amount being revealed from the confidential balance. + */ + MPTAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the holder's key + * (66 bytes). + */ + HolderEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the issuer's key + * (66 bytes). + */ + IssuerEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the auditor's key + * (66 bytes). Present only when an auditor key is registered. + */ + AuditorEncryptedAmount?: string + /** + * The scalar blinding factor (32 bytes) shared across the ciphertexts. + */ + BlindingFactor: string + /** + * The zero-knowledge proof binding the ciphertexts to the public amount and + * the resulting balance commitment. + */ + ZKProof: string + /** + * The Pedersen commitment to the holder's remaining confidential balance + * (33-byte EC point). + */ + BalanceCommitment: string +} + +/** + * Verify the form and type of a ConfidentialMPTConvertBack at runtime. + * + * @param tx - A ConfidentialMPTConvertBack Transaction. + * @throws When the ConfidentialMPTConvertBack is malformed. + */ +export function validateConfidentialMPTConvertBack( + tx: Record, +): void { + validateBaseTransaction(tx) + validateRequiredField(tx, 'MPTokenIssuanceID', isString) + validateRequiredField(tx, 'MPTAmount', isString) + validateRequiredField( + tx, + 'HolderEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateRequiredField( + tx, + 'IssuerEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateOptionalField( + tx, + 'AuditorEncryptedAmount', + isHexWithByteLength(CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES), + ) + validateRequiredField( + tx, + 'BlindingFactor', + isHexWithByteLength(CONFIDENTIAL_BLINDING_FACTOR_BYTES), + ) + validateRequiredField(tx, 'ZKProof', isHexBlob) + validateRequiredField( + tx, + 'BalanceCommitment', + isHexWithByteLength(CONFIDENTIAL_EC_POINT_BYTES), + ) +} diff --git a/packages/xrpl/src/models/transactions/ConfidentialMPTMergeInbox.ts b/packages/xrpl/src/models/transactions/ConfidentialMPTMergeInbox.ts new file mode 100644 index 0000000000..8f152f370e --- /dev/null +++ b/packages/xrpl/src/models/transactions/ConfidentialMPTMergeInbox.ts @@ -0,0 +1,33 @@ +import { + BaseTransaction, + isString, + validateBaseTransaction, + validateRequiredField, +} from './common' + +/** + * The ConfidentialMPTMergeInbox transaction folds a holder's pending + * confidential inbox balance into their spendable confidential balance. + * + * @category Transaction Models + */ +export interface ConfidentialMPTMergeInbox extends BaseTransaction { + TransactionType: 'ConfidentialMPTMergeInbox' + /** + * Identifies the MPTokenIssuance whose confidential inbox is being merged. + */ + MPTokenIssuanceID: string +} + +/** + * Verify the form and type of a ConfidentialMPTMergeInbox at runtime. + * + * @param tx - A ConfidentialMPTMergeInbox Transaction. + * @throws When the ConfidentialMPTMergeInbox is malformed. + */ +export function validateConfidentialMPTMergeInbox( + tx: Record, +): void { + validateBaseTransaction(tx) + validateRequiredField(tx, 'MPTokenIssuanceID', isString) +} diff --git a/packages/xrpl/src/models/transactions/ConfidentialMPTSend.ts b/packages/xrpl/src/models/transactions/ConfidentialMPTSend.ts new file mode 100644 index 0000000000..8308546348 --- /dev/null +++ b/packages/xrpl/src/models/transactions/ConfidentialMPTSend.ts @@ -0,0 +1,107 @@ +import { + Account, + BaseTransaction, + isAccount, + isHexBlob, + isHexWithByteLength, + isNumber, + isString, + validateBaseTransaction, + validateRequiredField, + validateOptionalField, + validateCredentialsList, + MAX_AUTHORIZED_CREDENTIALS, + CONFIDENTIAL_EC_POINT_BYTES, + CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES, +} from './common' + +/** + * The ConfidentialMPTSend transaction transfers a confidential (encrypted) MPT + * amount from the sender's confidential balance to a destination's confidential + * inbox, without revealing the amount on-ledger. + * + * @category Transaction Models + */ +export interface ConfidentialMPTSend extends BaseTransaction { + TransactionType: 'ConfidentialMPTSend' + /** + * Identifies the MPTokenIssuance being transferred. + */ + MPTokenIssuanceID: string + /** The unique address of the account receiving the confidential transfer. */ + Destination: Account + /** + * Arbitrary tag that identifies the reason for the transfer to the + * destination, or a hosted recipient to pay. + */ + DestinationTag?: number + /** + * ElGamal ciphertext of the amount encrypted under the sender's key + * (66 bytes). + */ + SenderEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the destination's key + * (66 bytes). + */ + DestinationEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the issuer's key + * (66 bytes). + */ + IssuerEncryptedAmount: string + /** + * ElGamal ciphertext of the amount encrypted under the auditor's key + * (66 bytes). Present only when an auditor key is registered. + */ + AuditorEncryptedAmount?: string + /** + * The zero-knowledge proof binding the ciphertexts, the amount commitment, + * and the resulting balance commitment. + */ + ZKProof: string + /** + * The Pedersen commitment to the transferred amount (33-byte EC point). + */ + AmountCommitment: string + /** + * The Pedersen commitment to the sender's remaining confidential balance + * (33-byte EC point). + */ + BalanceCommitment: string + /** + * Credentials associated with the sender of this transaction. + * The credentials included must not be expired. + */ + CredentialIDs?: string[] +} + +/** + * Verify the form and type of a ConfidentialMPTSend at runtime. + * + * @param tx - A ConfidentialMPTSend Transaction. + * @throws When the ConfidentialMPTSend is malformed. + */ +export function validateConfidentialMPTSend(tx: Record): void { + const isCiphertext = isHexWithByteLength( + CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES, + ) + const isCommitment = isHexWithByteLength(CONFIDENTIAL_EC_POINT_BYTES) + validateBaseTransaction(tx) + validateRequiredField(tx, 'MPTokenIssuanceID', isString) + validateRequiredField(tx, 'Destination', isAccount) + validateOptionalField(tx, 'DestinationTag', isNumber) + validateRequiredField(tx, 'SenderEncryptedAmount', isCiphertext) + validateRequiredField(tx, 'DestinationEncryptedAmount', isCiphertext) + validateRequiredField(tx, 'IssuerEncryptedAmount', isCiphertext) + validateOptionalField(tx, 'AuditorEncryptedAmount', isCiphertext) + validateRequiredField(tx, 'ZKProof', isHexBlob) + validateRequiredField(tx, 'AmountCommitment', isCommitment) + validateRequiredField(tx, 'BalanceCommitment', isCommitment) + validateCredentialsList( + tx.CredentialIDs, + tx.TransactionType, + true, + MAX_AUTHORIZED_CREDENTIALS, + ) +} diff --git a/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts b/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts index 6a6b6f9e32..cd19718820 100644 --- a/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts +++ b/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts @@ -55,6 +55,11 @@ export enum MPTokenIssuanceCreateFlags { * to clawback value from individual holders. */ tfMPTCanClawback = 0x00000040, + /** + * If set, indicates that holders may hold confidential (encrypted) balances + * of this token and use the Confidential MPT transactions. + */ + tfMPTCanConfidentialAmount = 0x00000080, } /** @@ -71,6 +76,7 @@ export interface MPTokenIssuanceCreateFlagsInterface extends GlobalFlagsInterfac tfMPTCanTrade?: boolean tfMPTCanTransfer?: boolean tfMPTCanClawback?: boolean + tfMPTCanConfidentialAmount?: boolean } /** diff --git a/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts b/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts index 12d15260ff..ec49781539 100644 --- a/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts +++ b/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts @@ -4,12 +4,14 @@ import { isFlagEnabled } from '../utils' import { BaseTransaction, isString, + isHexWithByteLength, validateBaseTransaction, validateRequiredField, Account, validateOptionalField, isAccount, GlobalFlagsInterface, + CONFIDENTIAL_EC_POINT_BYTES, } from './common' /** @@ -54,6 +56,16 @@ export interface MPTokenIssuanceSet extends BaseTransaction { * If omitted, this transaction will apply to all any accounts holding MPTs. */ Holder?: Account + /** + * The issuer's compressed ElGamal encryption key (33-byte EC point), + * registered so confidential amounts can be encrypted to the issuer. + */ + IssuerEncryptionKey?: string + /** + * The auditor's compressed ElGamal encryption key (33-byte EC point), + * registered so confidential amounts can be encrypted to an auditor. + */ + AuditorEncryptionKey?: string Flags?: number | MPTokenIssuanceSetFlagsInterface } @@ -67,6 +79,16 @@ export function validateMPTokenIssuanceSet(tx: Record): void { validateBaseTransaction(tx) validateRequiredField(tx, 'MPTokenIssuanceID', isString) validateOptionalField(tx, 'Holder', isAccount) + validateOptionalField( + tx, + 'IssuerEncryptionKey', + isHexWithByteLength(CONFIDENTIAL_EC_POINT_BYTES), + ) + validateOptionalField( + tx, + 'AuditorEncryptionKey', + isHexWithByteLength(CONFIDENTIAL_EC_POINT_BYTES), + ) // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Not necessary const flags = (tx.Flags ?? 0) as number | MPTokenIssuanceSetFlagsInterface diff --git a/packages/xrpl/src/models/transactions/common.ts b/packages/xrpl/src/models/transactions/common.ts index 42dfe47cbf..2478c88afb 100644 --- a/packages/xrpl/src/models/transactions/common.ts +++ b/packages/xrpl/src/models/transactions/common.ts @@ -24,6 +24,14 @@ const MAX_CREDENTIAL_BYTE_LENGTH = 64 const MAX_CREDENTIAL_TYPE_LENGTH = MAX_CREDENTIAL_BYTE_LENGTH * 2 const SHA_512_HALF_LENGTH = 64 +// Confidential MPT (XLS-0096) fixed field byte lengths. +// A compressed secp256k1 point (encryption keys and Pedersen commitments). +export const CONFIDENTIAL_EC_POINT_BYTES = 33 +// An ElGamal ciphertext is two compressed points. +export const CONFIDENTIAL_ELGAMAL_CIPHERTEXT_BYTES = 66 +// A scalar blinding factor (Hash256). +export const CONFIDENTIAL_BLINDING_FACTOR_BYTES = 32 + // Used for Vault transactions export const VAULT_DATA_MAX_BYTE_LENGTH = 256 @@ -370,6 +378,33 @@ export function validateHexMetadata( ) } +/** + * Verify the form and type of a non-empty hex string at runtime. + * + * @param inp - The value to check the form and type of. + * @returns Whether the value is a non-empty hex string. + */ +export function isHexBlob(inp: unknown): inp is string { + return isString(inp) && isHex(inp) +} + +/** + * Build a type guard that checks the input is a hex string encoding exactly + * `byteLength` bytes. Used by the Confidential MPT transactions to enforce + * fixed-size cryptographic fields (EC points, ElGamal ciphertexts, scalars). + * + * @param byteLength - The exact number of bytes the hex string must encode. + * @returns A type guard validating a hex string of the given byte length. + */ +export function isHexWithByteLength( + byteLength: number, +): (inp: unknown) => inp is string { + // eslint-disable-next-line func-style -- returning a type guard + const check = (inp: unknown): inp is string => + isString(inp) && isHex(inp) && inp.length === byteLength * 2 + return check +} + /* eslint-disable @typescript-eslint/restrict-template-expressions -- tx.TransactionType is checked before any calls */ /** diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index d7afb63012..86ab4c040d 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -43,6 +43,11 @@ export { CheckCancel } from './checkCancel' export { CheckCash } from './checkCash' export { CheckCreate } from './checkCreate' export { Clawback } from './clawback' +export { ConfidentialMPTClawback } from './ConfidentialMPTClawback' +export { ConfidentialMPTConvert } from './ConfidentialMPTConvert' +export { ConfidentialMPTConvertBack } from './ConfidentialMPTConvertBack' +export { ConfidentialMPTMergeInbox } from './ConfidentialMPTMergeInbox' +export { ConfidentialMPTSend } from './ConfidentialMPTSend' export { CredentialAccept } from './CredentialAccept' export { CredentialCreate } from './CredentialCreate' export { CredentialDelete } from './CredentialDelete' diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index 97f710a2ea..403d57035a 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -23,6 +23,26 @@ import { isIssuedCurrencyAmount, validateBaseTransaction, } from './common' +import { + ConfidentialMPTClawback, + validateConfidentialMPTClawback, +} from './ConfidentialMPTClawback' +import { + ConfidentialMPTConvert, + validateConfidentialMPTConvert, +} from './ConfidentialMPTConvert' +import { + ConfidentialMPTConvertBack, + validateConfidentialMPTConvertBack, +} from './ConfidentialMPTConvertBack' +import { + ConfidentialMPTMergeInbox, + validateConfidentialMPTMergeInbox, +} from './ConfidentialMPTMergeInbox' +import { + ConfidentialMPTSend, + validateConfidentialMPTSend, +} from './ConfidentialMPTSend' import { CredentialAccept, validateCredentialAccept } from './CredentialAccept' import { CredentialCreate, validateCredentialCreate } from './CredentialCreate' import { CredentialDelete, validateCredentialDelete } from './CredentialDelete' @@ -165,6 +185,11 @@ export type SubmittableTransaction = | CheckCash | CheckCreate | Clawback + | ConfidentialMPTClawback + | ConfidentialMPTConvert + | ConfidentialMPTConvertBack + | ConfidentialMPTMergeInbox + | ConfidentialMPTSend | CredentialAccept | CredentialCreate | CredentialDelete @@ -346,6 +371,26 @@ export function validate(transaction: Record): void { validateClawback(tx) break + case 'ConfidentialMPTClawback': + validateConfidentialMPTClawback(tx) + break + + case 'ConfidentialMPTConvert': + validateConfidentialMPTConvert(tx) + break + + case 'ConfidentialMPTConvertBack': + validateConfidentialMPTConvertBack(tx) + break + + case 'ConfidentialMPTMergeInbox': + validateConfidentialMPTMergeInbox(tx) + break + + case 'ConfidentialMPTSend': + validateConfidentialMPTSend(tx) + break + case 'CredentialAccept': validateCredentialAccept(tx) break diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index bb7809ac98..12d201c743 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -26,6 +26,17 @@ const LEDGER_OFFSET = 20 const RESTRICTED_NETWORKS = 1024 const REQUIRED_NETWORKID_VERSION = '1.11.0' +// Confidential MPT (XLS-0096) transactions are charged this many extra base +// fees on top of the standard base fee (rippled kCONFIDENTIAL_FEE_MULTIPLIER). +const CONFIDENTIAL_FEE_MULTIPLIER = 9 +const CONFIDENTIAL_MPT_TRANSACTION_TYPES = [ + 'ConfidentialMPTConvert', + 'ConfidentialMPTConvertBack', + 'ConfidentialMPTSend', + 'ConfidentialMPTClawback', + 'ConfidentialMPTMergeInbox', +] + /** * Determines whether the source rippled version is not later than the target rippled version. * Example usage: isNotLaterRippledVersion('1.10.0', '1.11.0') returns true. @@ -355,6 +366,16 @@ async function calculateFeePerTransactionType( Promise.resolve(new BigNumber(0)), ) baseFee = BigNumber.sum(baseFee.times(2), rawTxFees) + } else if (CONFIDENTIAL_MPT_TRANSACTION_TYPES.includes(tx.TransactionType)) { + /* + * Confidential MPT Transaction + * rippled charges kCONFIDENTIAL_FEE_MULTIPLIER (9) extra base fees on top of + * the standard base fee, so the total before signers is baseFee × 10. + */ + baseFee = BigNumber.sum( + baseFee, + scaleValue(netFeeDrops, CONFIDENTIAL_FEE_MULTIPLIER), + ) } /* diff --git a/packages/xrpl/test/client/autofill.test.ts b/packages/xrpl/test/client/autofill.test.ts index b68b2ddd08..1487ba0ab1 100644 --- a/packages/xrpl/test/client/autofill.test.ts +++ b/packages/xrpl/test/client/autofill.test.ts @@ -375,6 +375,48 @@ describe('client.autofill', function () { assert.strictEqual(txResult.Fee, '447') }) + + it('should autofill Fee of a confidential MPT transaction', async function () { + const tx: Transaction = { + Account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn', + TransactionType: 'ConfidentialMPTMergeInbox', + MPTokenIssuanceID: '000004C463C52827307480341125DA0577DEFC38405B0E3E', + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + + const txResult = await testContext.client.autofill(tx) + // base (12) × kCONFIDENTIAL_FEE_MULTIPLIER-plus-one (10) = 120. + assert.strictEqual(txResult.Fee, '120') + }) + + it('should autofill Fee of a confidential MPT transaction with signersCount', async function () { + const tx: Transaction = { + Account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn', + TransactionType: 'ConfidentialMPTMergeInbox', + MPTokenIssuanceID: '000004C463C52827307480341125DA0577DEFC38405B0E3E', + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + + const txResult = await testContext.client.autofill(tx, 4) + // base (12) × (10 + signersCount 4) = 168. + assert.strictEqual(txResult.Fee, '168') + }) }) it("should autofill LastLedgerSequence when it's missing", async function () { diff --git a/packages/xrpl/test/integration/confidentialMPTUtils.ts b/packages/xrpl/test/integration/confidentialMPTUtils.ts new file mode 100644 index 0000000000..d328df726b --- /dev/null +++ b/packages/xrpl/test/integration/confidentialMPTUtils.ts @@ -0,0 +1,286 @@ +import { decryptAmount } from '@xrplf/mpt-crypto' +import { assert } from 'chai' +import { deriveKeypair, generateSeed } from 'ripple-keypairs' + +import { Client, Wallet, type TransactionMetadata } from '../../src' +import { + fetchMPToken, + getConfidentialBalance, + prepareConfidentialConvert, + prepareConfidentialMergeInbox, + type ConfidentialKeypair, +} from '../../src/confidential' +import { + MPTokenIssuanceCreate, + MPTokenIssuanceSet, + Payment, +} from '../../src/models/transactions' + +import serverUrl from './serverUrl' +import { generateFundedWallet, testTransaction } from './utils' + +/* + * Shared helpers for the Confidential MPT (XLS-0096) integration tests. They run + * against a rippled with the MPTokensV1 + Clawback + ConfidentialTransfer + * amendments enabled (a local standalone for now; a CI docker image later), and + * reuse the standard harness (`testTransaction` drives autofill + sign + submit + + * `ledger_accept` + verify; `generateFundedWallet` funds from the genesis account). + */ + +export interface ConfidentialContext { + client: Client +} + +export interface Holder { + wallet: Wallet + key: ConfidentialKeypair +} + +/** + * Connect to a confidential-capable rippled. PR #5860 builds omit `network_id` + * from `server_info`, which makes `client.connect()` reject on current xrpl.js + * even though the socket is open; tolerate that and pin `networkID = 0`. (Once + * the CI image reports `network_id`, this can use the standard `setupClient`.) + * + * @param server - The WebSocket URL (defaults to the shared `serverUrl`). + * @returns A connected confidential test context. + */ +export async function setupConfidentialClient( + server = serverUrl, +): Promise { + const client = new Client(server, { timeout: 200000 }) + try { + await client.connect() + } catch { + // PR #5860 build omits network_id; the socket is still open. + } + client.networkID = 0 + assert.isTrue(client.isConnected(), 'confidential rippled connection is open') + return { client } +} + +/** + * Disconnect a confidential test context. + * + * @param context - The context to tear down. + */ +export async function teardownConfidential( + context: ConfidentialContext, +): Promise { + context.client.removeAllListeners() + await context.client.disconnect() +} + +/** + * Generate a fresh ElGamal keypair via ripple-keypairs — a dedicated secp256k1 + * seed, separate from any signing wallet. (The WASM no longer exposes keygen; + * confidential keys are derived from a seed.) + * + * @returns A fresh ElGamal keypair (33-byte public key, 32-byte private key). + */ +export function generateElGamalKeypair(): ConfidentialKeypair { + const { publicKey, privateKey } = deriveKeypair( + generateSeed({ algorithm: 'ecdsa-secp256k1' }), + ) + // ripple-keypairs prefixes secp256k1 private keys with `00`; mpt-crypto wants + // the bare 32-byte scalar. + return { publicKey, privateKey: privateKey.slice(2) } +} + +/** + * Create a confidential-capable, lockable MPT issuance and register the issuer + * (and, optionally, auditor) ElGamal encryption keys. + * + * `tfMPTCanLock` makes the issuance modifiable via MPTokenIssuanceSet without + * the DynamicMPT/SingleAssetVault amendment (rippled MPTokenIssuanceSet guard); + * the issuer + auditor keys must be registered together in one Set. + * + * @param client - A connected client. + * @param issuer - The issuer wallet. + * @param issuerKey - The issuer ElGamal keypair. + * @param auditorKey - An optional auditor ElGamal keypair to register. + * @returns The new MPTokenIssuanceID. + */ +// eslint-disable-next-line max-params -- (client, issuer, issuerKey, auditorKey) setup tuple +export async function createConfidentialIssuance( + client: Client, + issuer: Wallet, + issuerKey: ConfidentialKeypair, + auditorKey?: ConfidentialKeypair, +): Promise { + const createTx: MPTokenIssuanceCreate = { + TransactionType: 'MPTokenIssuanceCreate', + Account: issuer.classicAddress, + MaximumAmount: '9223372036854775807', + AssetScale: 0, + Flags: { + tfMPTCanLock: true, + tfMPTCanTransfer: true, + tfMPTCanClawback: true, + tfMPTCanConfidentialAmount: true, + }, + } + const created = await testTransaction(client, createTx, issuer) + const txResp = await client.request({ + command: 'tx', + transaction: created.result.tx_json.hash, + }) + const meta = txResp.result.meta as TransactionMetadata + const mptID = meta.mpt_issuance_id + if (mptID == null) { + throw new Error('MPTokenIssuanceCreate did not return an mpt_issuance_id') + } + + const setTx: MPTokenIssuanceSet = { + TransactionType: 'MPTokenIssuanceSet', + Account: issuer.classicAddress, + MPTokenIssuanceID: mptID, + IssuerEncryptionKey: issuerKey.publicKey, + } + if (auditorKey != null) { + setTx.AuditorEncryptionKey = auditorKey.publicKey + } + await testTransaction(client, setTx, issuer) + return mptID +} + +/** + * Fund a fresh holder, generate its ElGamal key, and opt it into the issuance. + * + * @param client - A connected client. + * @param mptID - The MPTokenIssuanceID. + * @returns The holder wallet and a fresh ElGamal keypair. + */ +export async function setupHolder( + client: Client, + mptID: string, +): Promise { + const wallet = await generateFundedWallet(client) + const key = generateElGamalKeypair() + await testTransaction( + client, + { + TransactionType: 'MPTokenAuthorize', + Account: wallet.classicAddress, + MPTokenIssuanceID: mptID, + }, + wallet, + ) + return { wallet, key } +} + +/** + * Register a holder's encryption key via a zero-amount convert (no balance). + * + * @param client - A connected client. + * @param mptID - The MPTokenIssuanceID. + * @returns The registered holder. + */ +export async function registerHolderKey( + client: Client, + mptID: string, +): Promise { + const holder = await setupHolder(client, mptID) + const convert = await prepareConfidentialConvert(client, { + account: holder.wallet.classicAddress, + amount: 0n, + holder: holder.key, + mptIssuanceID: mptID, + }) + await testTransaction(client, convert, holder.wallet) + return holder +} + +/** + * Give a fresh holder a spendable confidential balance: pay public MPT, then + * convert and merge it into the spendable balance. + * + * @param client - A connected client. + * @param issuer - The issuer wallet (pays the public MPT). + * @param mptID - The MPTokenIssuanceID. + * @param amount - The balance to establish. + * @returns The holder with `amount` spendable confidential balance. + */ +// eslint-disable-next-line max-params -- (client, issuer, mptID, amount) setup tuple +export async function holderWithBalance( + client: Client, + issuer: Wallet, + mptID: string, + amount: bigint, +): Promise { + const holder = await setupHolder(client, mptID) + const payment: Payment = { + TransactionType: 'Payment', + Account: issuer.classicAddress, + Destination: holder.wallet.classicAddress, + Amount: { mpt_issuance_id: mptID, value: amount.toString() }, + } + await testTransaction(client, payment, issuer) + await testTransaction( + client, + await prepareConfidentialConvert(client, { + account: holder.wallet.classicAddress, + amount, + holder: holder.key, + mptIssuanceID: mptID, + }), + holder.wallet, + ) + await testTransaction( + client, + await prepareConfidentialMergeInbox(client, { + account: holder.wallet.classicAddress, + mptIssuanceID: mptID, + }), + holder.wallet, + ) + return holder +} + +/** + * Read a holder's spendable confidential balance with its own private key. + * + * @param client - A connected client. + * @param holder - The holder. + * @param mptID - The MPTokenIssuanceID. + * @returns The decrypted spendable balance. + */ +export async function getSpendable( + client: Client, + holder: Holder, + mptID: string, +): Promise { + return getConfidentialBalance( + client, + holder.wallet.classicAddress, + mptID, + holder.key.privateKey, + ) +} + +/** + * Auditor selective disclosure: decrypt a holder's balance with the auditor key. + * + * @param client - A connected client. + * @param holderAddress - The holder's classic address. + * @param mptID - The MPTokenIssuanceID. + * @param auditorKey - The auditor ElGamal keypair. + * @returns The decrypted balance the auditor sees. + */ +// eslint-disable-next-line max-params -- (client, holder, mptID, auditorKey) disclosure tuple +export async function auditorReads( + client: Client, + holderAddress: string, + mptID: string, + auditorKey: ConfidentialKeypair, +): Promise { + const token = await fetchMPToken(client, holderAddress, mptID) + assert.isString( + token.AuditorEncryptedBalance, + 'holder MPToken carries an AuditorEncryptedBalance', + ) + return decryptAmount( + token.AuditorEncryptedBalance as string, + auditorKey.privateKey, + ) +} diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTClawback.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTClawback.test.ts new file mode 100644 index 0000000000..4567c54e54 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTClawback.test.ts @@ -0,0 +1,71 @@ +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + prepareConfidentialClawback, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + holderWithBalance, + setupConfidentialClient, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +const SETUP_TIMEOUT = 60000 +const TIMEOUT = 120000 + +describe('ConfidentialMPTClawback', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'lets the issuer claw back a holder confidential balance', + async () => { + const holder = await holderWithBalance( + testContext.client, + issuer, + mptID, + 1000n, + ) + + await testTransaction( + testContext.client, + await prepareConfidentialClawback(testContext.client, { + account: issuer.classicAddress, + holder: holder.wallet.classicAddress, + issuer: issuerKey, + mptIssuanceID: mptID, + }), + issuer, + ) + + assert.strictEqual( + await getSpendable(testContext.client, holder, mptID), + 0n, + 'the holder confidential balance is zeroed', + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTConvert.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTConvert.test.ts new file mode 100644 index 0000000000..af7de885bd --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTConvert.test.ts @@ -0,0 +1,98 @@ +import { decryptAmount } from '@xrplf/mpt-crypto' +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + fetchMPToken, + prepareConfidentialConvert, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { Payment } from '../../../src/models/transactions' +import { + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + setupConfidentialClient, + setupHolder, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +/* + * Requires a rippled with the MPTokensV1 + Clawback + ConfidentialTransfer + * amendments enabled — the `develop` CI image once PR #5860 lands (a local + * standalone in the meantime). + */ +const SETUP_TIMEOUT = 60000 +const TIMEOUT = 60000 + +describe('ConfidentialMPTConvert', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'moves a public balance into the confidential inbox and registers the holder key', + async () => { + const holder = await setupHolder(testContext.client, mptID) + const payment: Payment = { + TransactionType: 'Payment', + Account: issuer.classicAddress, + Destination: holder.wallet.classicAddress, + Amount: { mpt_issuance_id: mptID, value: '1000' }, + } + await testTransaction(testContext.client, payment, issuer) + + const convert = await prepareConfidentialConvert(testContext.client, { + account: holder.wallet.classicAddress, + amount: 1000n, + holder: holder.key, + mptIssuanceID: mptID, + }) + await testTransaction(testContext.client, convert, holder.wallet) + + const token = await fetchMPToken( + testContext.client, + holder.wallet.classicAddress, + mptID, + ) + assert.strictEqual( + token.HolderEncryptionKey, + holder.key.publicKey, + 'the holder encryption key is registered', + ) + assert.isString(token.ConfidentialBalanceInbox) + assert.strictEqual( + await decryptAmount( + token.ConfidentialBalanceInbox as string, + holder.key.privateKey, + ), + 1000n, + 'the inbox holds the converted amount', + ) + // The amount is not yet spendable (it must be merged first). + assert.strictEqual( + await getSpendable(testContext.client, holder, mptID), + 0n, + 'the spendable balance is still empty before merge', + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTConvertBack.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTConvertBack.test.ts new file mode 100644 index 0000000000..cab17c40a2 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTConvertBack.test.ts @@ -0,0 +1,71 @@ +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + prepareConfidentialConvertBack, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + holderWithBalance, + setupConfidentialClient, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +const SETUP_TIMEOUT = 60000 +const TIMEOUT = 120000 + +describe('ConfidentialMPTConvertBack', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'reveals a public amount from the confidential balance', + async () => { + const holder = await holderWithBalance( + testContext.client, + issuer, + mptID, + 1000n, + ) + + await testTransaction( + testContext.client, + await prepareConfidentialConvertBack(testContext.client, { + account: holder.wallet.classicAddress, + amount: 400n, + holder: holder.key, + mptIssuanceID: mptID, + }), + holder.wallet, + ) + + assert.strictEqual( + await getSpendable(testContext.client, holder, mptID), + 600n, + 'spendable is reduced by the revealed amount', + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTLifecycle.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTLifecycle.test.ts new file mode 100644 index 0000000000..191ee2c9ea --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTLifecycle.test.ts @@ -0,0 +1,148 @@ +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + prepareConfidentialClawback, + prepareConfidentialConvertBack, + prepareConfidentialMergeInbox, + prepareConfidentialSend, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { + auditorReads, + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + holderWithBalance, + registerHolderKey, + setupConfidentialClient, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +/* + * The four-party scenario (issuer, auditor, and two holders). It exercises every + * confidential transaction type in sequence and verifies auditor selective + * disclosure (the auditor decrypts each holder's balance) after each change. + * Requires the MPTokensV1 + Clawback + ConfidentialTransfer amendments (see + * ./confidentialMPTUtils.ts). + */ +const SETUP_TIMEOUT = 60000 +const LIFECYCLE_TIMEOUT = 240000 + +describe('Confidential MPT 4-party lifecycle', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let auditorKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + auditorKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + auditorKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'runs convert, merge, send, convert-back, and clawback with auditor disclosure', + async () => { + const client = testContext.client + + // Holder1 converts 1000 public -> confidential and merges. + const holder1 = await holderWithBalance(client, issuer, mptID, 1000n) + assert.strictEqual(await getSpendable(client, holder1, mptID), 1000n) + assert.strictEqual( + await auditorReads( + client, + holder1.wallet.classicAddress, + mptID, + auditorKey, + ), + 1000n, + 'auditor sees holder1 = 1000 after convert', + ) + + // Holder2 registers its key; holder1 sends 300; holder2 merges. + const holder2 = await registerHolderKey(client, mptID) + await testTransaction( + client, + await prepareConfidentialSend(client, { + account: holder1.wallet.classicAddress, + destination: holder2.wallet.classicAddress, + amount: 300n, + sender: holder1.key, + mptIssuanceID: mptID, + }), + holder1.wallet, + ) + await testTransaction( + client, + await prepareConfidentialMergeInbox(client, { + account: holder2.wallet.classicAddress, + mptIssuanceID: mptID, + }), + holder2.wallet, + ) + assert.strictEqual(await getSpendable(client, holder1, mptID), 700n) + assert.strictEqual(await getSpendable(client, holder2, mptID), 300n) + assert.strictEqual( + await auditorReads( + client, + holder1.wallet.classicAddress, + mptID, + auditorKey, + ), + 700n, + 'auditor sees holder1 = 700 after send', + ) + assert.strictEqual( + await auditorReads( + client, + holder2.wallet.classicAddress, + mptID, + auditorKey, + ), + 300n, + 'auditor sees holder2 = 300 after receive', + ) + + // Holder1 reveals 200 back to public. + await testTransaction( + client, + await prepareConfidentialConvertBack(client, { + account: holder1.wallet.classicAddress, + amount: 200n, + holder: holder1.key, + mptIssuanceID: mptID, + }), + holder1.wallet, + ) + assert.strictEqual(await getSpendable(client, holder1, mptID), 500n) + + // Issuer claws back holder1's remaining balance. + await testTransaction( + client, + await prepareConfidentialClawback(client, { + account: issuer.classicAddress, + holder: holder1.wallet.classicAddress, + issuer: issuerKey, + mptIssuanceID: mptID, + }), + issuer, + ) + assert.strictEqual(await getSpendable(client, holder1, mptID), 0n) + }, + LIFECYCLE_TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTMergeInbox.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTMergeInbox.test.ts new file mode 100644 index 0000000000..90de458d85 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTMergeInbox.test.ts @@ -0,0 +1,90 @@ +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + prepareConfidentialConvert, + prepareConfidentialMergeInbox, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { Payment } from '../../../src/models/transactions' +import { + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + setupConfidentialClient, + setupHolder, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +const SETUP_TIMEOUT = 60000 +const TIMEOUT = 60000 + +describe('ConfidentialMPTMergeInbox', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'folds the confidential inbox into the spendable balance', + async () => { + const holder = await setupHolder(testContext.client, mptID) + const payment: Payment = { + TransactionType: 'Payment', + Account: issuer.classicAddress, + Destination: holder.wallet.classicAddress, + Amount: { mpt_issuance_id: mptID, value: '500' }, + } + await testTransaction(testContext.client, payment, issuer) + await testTransaction( + testContext.client, + await prepareConfidentialConvert(testContext.client, { + account: holder.wallet.classicAddress, + amount: 500n, + holder: holder.key, + mptIssuanceID: mptID, + }), + holder.wallet, + ) + + // After convert the amount is in the inbox, not yet spendable. + assert.strictEqual( + await getSpendable(testContext.client, holder, mptID), + 0n, + 'spendable is empty before merge', + ) + + await testTransaction( + testContext.client, + await prepareConfidentialMergeInbox(testContext.client, { + account: holder.wallet.classicAddress, + mptIssuanceID: mptID, + }), + holder.wallet, + ) + + assert.strictEqual( + await getSpendable(testContext.client, holder, mptID), + 500n, + 'spendable equals the merged amount', + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/confidentialMPTSend.test.ts b/packages/xrpl/test/integration/transactions/confidentialMPTSend.test.ts new file mode 100644 index 0000000000..c9ab604a69 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/confidentialMPTSend.test.ts @@ -0,0 +1,91 @@ +import { decryptAmount } from '@xrplf/mpt-crypto' +import { assert } from 'chai' + +import { Wallet } from '../../../src' +import { + fetchMPToken, + prepareConfidentialSend, + type ConfidentialKeypair, +} from '../../../src/confidential' +import { + createConfidentialIssuance, + generateElGamalKeypair, + getSpendable, + holderWithBalance, + registerHolderKey, + setupConfidentialClient, + teardownConfidential, + type ConfidentialContext, +} from '../confidentialMPTUtils' +import serverUrl from '../serverUrl' +import { generateFundedWallet, testTransaction } from '../utils' + +const SETUP_TIMEOUT = 60000 +const TIMEOUT = 120000 + +describe('ConfidentialMPTSend', function () { + let testContext: ConfidentialContext + let issuer: Wallet + let issuerKey: ConfidentialKeypair + let mptID: string + + beforeAll(async () => { + testContext = await setupConfidentialClient(serverUrl) + issuer = await generateFundedWallet(testContext.client) + issuerKey = generateElGamalKeypair() + mptID = await createConfidentialIssuance( + testContext.client, + issuer, + issuerKey, + ) + }, SETUP_TIMEOUT) + + afterAll(async () => teardownConfidential(testContext)) + + it( + 'transfers a confidential amount into the destination inbox', + async () => { + const sender = await holderWithBalance( + testContext.client, + issuer, + mptID, + 1000n, + ) + const dest = await registerHolderKey(testContext.client, mptID) + + await testTransaction( + testContext.client, + await prepareConfidentialSend(testContext.client, { + account: sender.wallet.classicAddress, + destination: dest.wallet.classicAddress, + amount: 300n, + sender: sender.key, + mptIssuanceID: mptID, + }), + sender.wallet, + ) + + assert.strictEqual( + await getSpendable(testContext.client, sender, mptID), + 700n, + 'sender balance is reduced by the sent amount', + ) + + const destToken = await fetchMPToken( + testContext.client, + dest.wallet.classicAddress, + mptID, + ) + assert.isString(destToken.ConfidentialBalanceInbox) + assert.strictEqual( + await decryptAmount( + destToken.ConfidentialBalanceInbox as string, + dest.key.privateKey, + ), + 300n, + 'destination inbox received the sent amount', + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/models/ConfidentialMPTClawback.test.ts b/packages/xrpl/test/models/ConfidentialMPTClawback.test.ts new file mode 100644 index 0000000000..1e88408439 --- /dev/null +++ b/packages/xrpl/test/models/ConfidentialMPTClawback.test.ts @@ -0,0 +1,110 @@ +import { validateConfidentialMPTClawback } from '../../src/models/transactions/ConfidentialMPTClawback' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateConfidentialMPTClawback) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateConfidentialMPTClawback, message) + +const ACCOUNT = 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm' +const HOLDER = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' +const MPT_ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' +const PROOF = 'AB'.repeat(64) + +/** + * ConfidentialMPTClawback Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('ConfidentialMPTClawback', function () { + it(`verifies valid ConfidentialMPTClawback`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Holder: HOLDER, + MPTAmount: '100', + ZKProof: PROOF, + }) + }) + + it(`throws w/ missing MPTokenIssuanceID`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + Holder: HOLDER, + MPTAmount: '100', + ZKProof: PROOF, + }, + 'ConfidentialMPTClawback: missing field MPTokenIssuanceID', + ) + }) + + it(`throws w/ missing Holder`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + ZKProof: PROOF, + }, + 'ConfidentialMPTClawback: missing field Holder', + ) + }) + + it(`throws w/ missing MPTAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Holder: HOLDER, + ZKProof: PROOF, + }, + 'ConfidentialMPTClawback: missing field MPTAmount', + ) + }) + + it(`throws w/ missing ZKProof`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Holder: HOLDER, + MPTAmount: '100', + }, + 'ConfidentialMPTClawback: missing field ZKProof', + ) + }) + + it(`throws w/ invalid Holder`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Holder: 'not-an-address', + MPTAmount: '100', + ZKProof: PROOF, + }, + 'ConfidentialMPTClawback: invalid field Holder', + ) + }) + + it(`throws w/ non-hex ZKProof`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTClawback', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Holder: HOLDER, + MPTAmount: '100', + ZKProof: 'nothex', + }, + 'ConfidentialMPTClawback: invalid field ZKProof', + ) + }) +}) diff --git a/packages/xrpl/test/models/ConfidentialMPTConvert.test.ts b/packages/xrpl/test/models/ConfidentialMPTConvert.test.ts new file mode 100644 index 0000000000..8aebd7ce91 --- /dev/null +++ b/packages/xrpl/test/models/ConfidentialMPTConvert.test.ts @@ -0,0 +1,171 @@ +import { validateConfidentialMPTConvert } from '../../src/models/transactions/ConfidentialMPTConvert' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateConfidentialMPTConvert) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateConfidentialMPTConvert, message) + +const ACCOUNT = 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm' +const MPT_ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' +// 33-byte compressed EC point (encryption key). +const EC_POINT = `02${'AB'.repeat(32)}` +// 66-byte ElGamal ciphertext (two compressed points). +const CIPHERTEXT = `02${'AB'.repeat(32)}03${'CD'.repeat(32)}` +// 32-byte scalar blinding factor. +const BLINDING = 'AB'.repeat(32) +const PROOF = 'AB'.repeat(64) + +/** + * ConfidentialMPTConvert Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('ConfidentialMPTConvert', function () { + it(`verifies valid ConfidentialMPTConvert with all fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptionKey: EC_POINT, + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + }) + }) + + it(`verifies valid ConfidentialMPTConvert with only required fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }) + }) + + it(`throws w/ missing MPTokenIssuanceID`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: missing field MPTokenIssuanceID', + ) + }) + + it(`throws w/ missing MPTAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: missing field MPTAmount', + ) + }) + + it(`throws w/ missing HolderEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: missing field HolderEncryptedAmount', + ) + }) + + it(`throws w/ missing BlindingFactor`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + }, + 'ConfidentialMPTConvert: missing field BlindingFactor', + ) + }) + + it(`throws w/ wrong-length HolderEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + // 33-byte EC point where a 66-byte ciphertext is required. + HolderEncryptedAmount: EC_POINT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: invalid field HolderEncryptedAmount', + ) + }) + + it(`throws w/ wrong-length BlindingFactor`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + // 33-byte value where a 32-byte scalar is required. + BlindingFactor: EC_POINT, + }, + 'ConfidentialMPTConvert: invalid field BlindingFactor', + ) + }) + + it(`throws w/ wrong-length HolderEncryptionKey`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + // 66-byte value where a 33-byte EC point is required. + HolderEncryptionKey: CIPHERTEXT, + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: invalid field HolderEncryptionKey', + ) + }) + + it(`throws w/ non-hex IssuerEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvert', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: `ZZ${'AB'.repeat(65)}`, + BlindingFactor: BLINDING, + }, + 'ConfidentialMPTConvert: invalid field IssuerEncryptedAmount', + ) + }) +}) diff --git a/packages/xrpl/test/models/ConfidentialMPTConvertBack.test.ts b/packages/xrpl/test/models/ConfidentialMPTConvertBack.test.ts new file mode 100644 index 0000000000..23fcbac4a6 --- /dev/null +++ b/packages/xrpl/test/models/ConfidentialMPTConvertBack.test.ts @@ -0,0 +1,138 @@ +import { validateConfidentialMPTConvertBack } from '../../src/models/transactions/ConfidentialMPTConvertBack' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateConfidentialMPTConvertBack) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateConfidentialMPTConvertBack, message) + +const ACCOUNT = 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm' +const MPT_ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' +// 33-byte compressed EC point (Pedersen commitment). +const EC_POINT = `02${'AB'.repeat(32)}` +// 66-byte ElGamal ciphertext (two compressed points). +const CIPHERTEXT = `02${'AB'.repeat(32)}03${'CD'.repeat(32)}` +// 32-byte scalar blinding factor. +const BLINDING = 'AB'.repeat(32) +const PROOF = 'AB'.repeat(408) + +/** + * ConfidentialMPTConvertBack Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('ConfidentialMPTConvertBack', function () { + it(`verifies valid ConfidentialMPTConvertBack with all fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + BalanceCommitment: EC_POINT, + }) + }) + + it(`verifies valid ConfidentialMPTConvertBack with only required fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + BalanceCommitment: EC_POINT, + }) + }) + + it(`throws w/ missing ZKProof`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTConvertBack: missing field ZKProof', + ) + }) + + it(`throws w/ missing BalanceCommitment`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + }, + 'ConfidentialMPTConvertBack: missing field BalanceCommitment', + ) + }) + + it(`throws w/ missing IssuerEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTConvertBack: missing field IssuerEncryptedAmount', + ) + }) + + it(`throws w/ wrong-length BalanceCommitment`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + // 66-byte value where a 33-byte EC point is required. + BalanceCommitment: CIPHERTEXT, + }, + 'ConfidentialMPTConvertBack: invalid field BalanceCommitment', + ) + }) + + it(`throws w/ wrong-length AuditorEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTConvertBack', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + MPTAmount: '100', + HolderEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + // 33-byte value where a 66-byte ciphertext is required. + AuditorEncryptedAmount: EC_POINT, + BlindingFactor: BLINDING, + ZKProof: PROOF, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTConvertBack: invalid field AuditorEncryptedAmount', + ) + }) +}) diff --git a/packages/xrpl/test/models/ConfidentialMPTMergeInbox.test.ts b/packages/xrpl/test/models/ConfidentialMPTMergeInbox.test.ts new file mode 100644 index 0000000000..5a61ed75d6 --- /dev/null +++ b/packages/xrpl/test/models/ConfidentialMPTMergeInbox.test.ts @@ -0,0 +1,46 @@ +import { validateConfidentialMPTMergeInbox } from '../../src/models/transactions/ConfidentialMPTMergeInbox' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateConfidentialMPTMergeInbox) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateConfidentialMPTMergeInbox, message) + +const ACCOUNT = 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm' +const MPT_ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' + +/** + * ConfidentialMPTMergeInbox Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('ConfidentialMPTMergeInbox', function () { + it(`verifies valid ConfidentialMPTMergeInbox`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTMergeInbox', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + }) + }) + + it(`throws w/ missing MPTokenIssuanceID`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTMergeInbox', + Account: ACCOUNT, + }, + 'ConfidentialMPTMergeInbox: missing field MPTokenIssuanceID', + ) + }) + + it(`throws w/ non-string MPTokenIssuanceID`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTMergeInbox', + Account: ACCOUNT, + MPTokenIssuanceID: 12345, + }, + 'ConfidentialMPTMergeInbox: invalid field MPTokenIssuanceID', + ) + }) +}) diff --git a/packages/xrpl/test/models/ConfidentialMPTSend.test.ts b/packages/xrpl/test/models/ConfidentialMPTSend.test.ts new file mode 100644 index 0000000000..78ca7b7411 --- /dev/null +++ b/packages/xrpl/test/models/ConfidentialMPTSend.test.ts @@ -0,0 +1,202 @@ +import { validateConfidentialMPTSend } from '../../src/models/transactions/ConfidentialMPTSend' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateConfidentialMPTSend) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateConfidentialMPTSend, message) + +const ACCOUNT = 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm' +const DESTINATION = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' +const MPT_ISSUANCE_ID = '000004C463C52827307480341125DA0577DEFC38405B0E3E' +// 33-byte compressed EC point (Pedersen commitment). +const EC_POINT = `02${'AB'.repeat(32)}` +// 66-byte ElGamal ciphertext (two compressed points). +const CIPHERTEXT = `02${'AB'.repeat(32)}03${'CD'.repeat(32)}` +// Fixed 946-byte ConfidentialMPTSend proof. +const PROOF = 'AB'.repeat(946) +const CREDENTIAL_ID = + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A' + +/** + * ConfidentialMPTSend Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('ConfidentialMPTSend', function () { + it(`verifies valid ConfidentialMPTSend with all fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + DestinationTag: 12345, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AuditorEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + CredentialIDs: [CREDENTIAL_ID], + }) + }) + + it(`verifies valid ConfidentialMPTSend with only required fields`, function () { + assertValid({ + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }) + }) + + it(`throws w/ missing Destination`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: missing field Destination', + ) + }) + + it(`throws w/ missing SenderEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: missing field SenderEncryptedAmount', + ) + }) + + it(`throws w/ missing AmountCommitment`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: missing field AmountCommitment', + ) + }) + + it(`throws w/ missing ZKProof`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: missing field ZKProof', + ) + }) + + it(`throws w/ invalid Destination`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: 'not-an-address', + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: invalid field Destination', + ) + }) + + it(`throws w/ wrong-length DestinationEncryptedAmount`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + SenderEncryptedAmount: CIPHERTEXT, + // 33-byte EC point where a 66-byte ciphertext is required. + DestinationEncryptedAmount: EC_POINT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: invalid field DestinationEncryptedAmount', + ) + }) + + it(`throws w/ wrong-length AmountCommitment`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + // 66-byte value where a 33-byte EC point is required. + AmountCommitment: CIPHERTEXT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: invalid field AmountCommitment', + ) + }) + + it(`throws w/ invalid DestinationTag`, function () { + assertInvalid( + { + TransactionType: 'ConfidentialMPTSend', + Account: ACCOUNT, + MPTokenIssuanceID: MPT_ISSUANCE_ID, + Destination: DESTINATION, + DestinationTag: 'not-a-number', + SenderEncryptedAmount: CIPHERTEXT, + DestinationEncryptedAmount: CIPHERTEXT, + IssuerEncryptedAmount: CIPHERTEXT, + ZKProof: PROOF, + AmountCommitment: EC_POINT, + BalanceCommitment: EC_POINT, + }, + 'ConfidentialMPTSend: invalid field DestinationTag', + ) + }) +}) diff --git a/packages/xrpl/test/models/MPTokenIssuanceCreate.test.ts b/packages/xrpl/test/models/MPTokenIssuanceCreate.test.ts index 3e3c241e6d..ee785b7796 100644 --- a/packages/xrpl/test/models/MPTokenIssuanceCreate.test.ts +++ b/packages/xrpl/test/models/MPTokenIssuanceCreate.test.ts @@ -41,6 +41,20 @@ describe('MPTokenIssuanceCreate', function () { assertValid(validMPTokenIssuanceCreate) }) + it(`verifies valid MPTokenIssuanceCreate w/ tfMPTCanConfidentialAmount`, function () { + assertValid({ + TransactionType: 'MPTokenIssuanceCreate', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Flags: MPTokenIssuanceCreateFlags.tfMPTCanConfidentialAmount, + } as any) + + assertValid({ + TransactionType: 'MPTokenIssuanceCreate', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Flags: { tfMPTCanConfidentialAmount: true }, + } as any) + }) + it(`throws w/ MPTokenMetadata being an empty string`, function () { const invalid = { TransactionType: 'MPTokenIssuanceCreate', diff --git a/packages/xrpl/test/models/MPTokenIssuanceSet.test.ts b/packages/xrpl/test/models/MPTokenIssuanceSet.test.ts index 2a05f2a71a..22dab990c7 100644 --- a/packages/xrpl/test/models/MPTokenIssuanceSet.test.ts +++ b/packages/xrpl/test/models/MPTokenIssuanceSet.test.ts @@ -75,4 +75,30 @@ describe('MPTokenIssuanceSet', function () { assertInvalid(invalid, 'MPTokenIssuanceSet: flag conflict') }) + + it(`verifies valid MPTokenIssuanceSet w/ confidential encryption keys`, function () { + // 33-byte compressed EC point. + const EC_POINT = `02${'AB'.repeat(32)}` + + assertValid({ + TransactionType: 'MPTokenIssuanceSet', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + MPTokenIssuanceID: TOKEN_ID, + IssuerEncryptionKey: EC_POINT, + AuditorEncryptionKey: EC_POINT, + } as any) + }) + + it(`throws w/ wrong-length IssuerEncryptionKey`, function () { + assertInvalid( + { + TransactionType: 'MPTokenIssuanceSet', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + MPTokenIssuanceID: TOKEN_ID, + // 32-byte value where a 33-byte EC point is required. + IssuerEncryptionKey: 'AB'.repeat(32), + } as any, + 'MPTokenIssuanceSet: invalid field IssuerEncryptionKey', + ) + }) }) diff --git a/packages/xrpl/tsconfig.build.json b/packages/xrpl/tsconfig.build.json index 9599cd590f..0e03f03a28 100644 --- a/packages/xrpl/tsconfig.build.json +++ b/packages/xrpl/tsconfig.build.json @@ -8,6 +8,9 @@ { "path": "../isomorphic/tsconfig.build.json" }, + { + "path": "../mpt-crypto/tsconfig.build.json" + }, { "path": "../ripple-address-codec/tsconfig.build.json" },