From 122e069d914622343fa1a21c3990a2f416ea9dfe Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 12 Oct 2016 23:30:10 +0200 Subject: [PATCH] crypto for refreshing --- .vscode/settings.json | 5 +- lib/emscripten/emsc.d.ts | 2 + lib/wallet/emscriptif.ts | 371 +++++++++++++++++++++++++++------------ lib/wallet/helpers.ts | 6 +- lib/wallet/types.ts | 66 +++++-- pages/tree.tsx | 25 +++ 6 files changed, 351 insertions(+), 124 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d6f4a1a1d..e17e44c92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ // Place your settings in this file to overwrite default and user settings. { - // Use latest language services + // Use latest language servicesu "typescript.tsdk": "node_modules/typescript/lib", // Defines space handling after a comma delimiter "typescript.format.insertSpaceAfterCommaDelimiter": true, @@ -33,5 +33,6 @@ "when": "$(basename).tsx" }, "**/*.js.map": true - } + }, + "editor.wrappingIndent": "same" } \ No newline at end of file diff --git a/lib/emscripten/emsc.d.ts b/lib/emscripten/emsc.d.ts index b9690433f..0b180781a 100644 --- a/lib/emscripten/emsc.d.ts +++ b/lib/emscripten/emsc.d.ts @@ -33,6 +33,8 @@ export interface EmscFunGen { export declare namespace Module { var cwrap: EmscFunGen; + function ccall(name: string, ret:"number"|"string", argTypes: any[], args: any[]): any + function stringToUTF8(s: string, addr: number, maxLength: number): void function _free(ptr: number): void; diff --git a/lib/wallet/emscriptif.ts b/lib/wallet/emscriptif.ts index 5879300e7..23014114a 100644 --- a/lib/wallet/emscriptif.ts +++ b/lib/wallet/emscriptif.ts @@ -14,13 +14,13 @@ TALER; see the file COPYING. If not, see */ -import {AmountJson} from "./types"; +import { AmountJson } from "./types"; import * as EmscWrapper from "../emscripten/emsc"; /** * High-level interface to emscripten-compiled modules used * by the wallet. - * @module EmscriptIf + * * @author Florian Dold */ @@ -43,110 +43,122 @@ let getEmsc: EmscWrapper.EmscFunGen = (...args: any[]) => Module.cwrap.apply( var emsc = { free: (ptr: number) => Module._free(ptr), get_value: getEmsc('TALER_WR_get_value', - 'number', - ['number']), + 'number', + ['number']), get_fraction: getEmsc('TALER_WR_get_fraction', - 'number', - ['number']), + 'number', + ['number']), get_currency: getEmsc('TALER_WR_get_currency', - 'string', - ['number']), + 'string', + ['number']), amount_add: getEmsc('TALER_amount_add', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), amount_subtract: getEmsc('TALER_amount_subtract', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), amount_normalize: getEmsc('TALER_amount_normalize', - 'void', - ['number']), + 'void', + ['number']), amount_get_zero: getEmsc('TALER_amount_get_zero', - 'number', - ['string', 'number']), + 'number', + ['string', 'number']), amount_cmp: getEmsc('TALER_amount_cmp', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), amount_hton: getEmsc('TALER_amount_hton', - 'void', - ['number', 'number']), + 'void', + ['number', 'number']), amount_ntoh: getEmsc('TALER_amount_ntoh', - 'void', - ['number', 'number']), + 'void', + ['number', 'number']), hash: getEmsc('GNUNET_CRYPTO_hash', - 'void', - ['number', 'number', 'number']), + 'void', + ['number', 'number', 'number']), memmove: getEmsc('memmove', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), rsa_public_key_free: getEmsc('GNUNET_CRYPTO_rsa_public_key_free', - 'void', - ['number']), + 'void', + ['number']), rsa_signature_free: getEmsc('GNUNET_CRYPTO_rsa_signature_free', - 'void', - ['number']), + 'void', + ['number']), string_to_data: getEmsc('GNUNET_STRINGS_string_to_data', - 'number', - ['number', 'number', 'number', 'number']), + 'number', + ['number', 'number', 'number', 'number']), eddsa_sign: getEmsc('GNUNET_CRYPTO_eddsa_sign', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), eddsa_verify: getEmsc('GNUNET_CRYPTO_eddsa_verify', - 'number', - ['number', 'number', 'number', 'number']), + 'number', + ['number', 'number', 'number', 'number']), hash_create_random: getEmsc('GNUNET_CRYPTO_hash_create_random', - 'void', - ['number', 'number']), + 'void', + ['number', 'number']), rsa_blinding_key_destroy: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_free', - 'void', - ['number']), + 'void', + ['number']), random_block: getEmsc('GNUNET_CRYPTO_random_block', - 'void', - ['number', 'number', 'number']), + 'void', + ['number', 'number', 'number']), + hash_context_abort: getEmsc('GNUNET_CRYPTO_hash_context_abort', + 'void', + ['number']), + hash_context_read: getEmsc('GNUNET_CRYPTO_hash_context_read', + 'void', + ['number', 'number', 'number']), + hash_context_finish: getEmsc('GNUNET_CRYPTO_hash_context_finish', + 'void', + ['number', 'number']), }; var emscAlloc = { get_amount: getEmsc('TALER_WRALL_get_amount', - 'number', - ['number', 'number', 'number', 'string']), + 'number', + ['number', 'number', 'number', 'string']), eddsa_key_create: getEmsc('GNUNET_CRYPTO_eddsa_key_create', - 'number', []), + 'number', []), eddsa_public_key_from_private: getEmsc( 'TALER_WRALL_eddsa_public_key_from_private', 'number', ['number']), data_to_string_alloc: getEmsc('GNUNET_STRINGS_data_to_string_alloc', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), purpose_create: getEmsc('TALER_WRALL_purpose_create', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), rsa_blind: getEmsc('GNUNET_CRYPTO_rsa_blind', - 'number', - ['number', 'number', 'number', 'number']), + 'number', + ['number', 'number', 'number', 'number']), rsa_blinding_key_create: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_create', - 'number', - ['number']), + 'number', + ['number']), rsa_blinding_key_encode: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_encode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_signature_encode: getEmsc('GNUNET_CRYPTO_rsa_signature_encode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_blinding_key_decode: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_decode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_public_key_decode: getEmsc('GNUNET_CRYPTO_rsa_public_key_decode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_signature_decode: getEmsc('GNUNET_CRYPTO_rsa_signature_decode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_public_key_encode: getEmsc('GNUNET_CRYPTO_rsa_public_key_encode', - 'number', - ['number', 'number']), + 'number', + ['number', 'number']), rsa_unblind: getEmsc('GNUNET_CRYPTO_rsa_unblind', - 'number', - ['number', 'number', 'number']), + 'number', + ['number', 'number', 'number']), + hash_context_start: getEmsc('GNUNET_CRYPTO_hash_context_start', + 'number', + []), malloc: (size: number) => Module._malloc(size), }; @@ -155,6 +167,7 @@ export enum SignaturePurpose { RESERVE_WITHDRAW = 1200, WALLET_COIN_DEPOSIT = 1201, MASTER_DENOMINATION_KEY_VALIDITY = 1025, + WALLET_COIN_MELT = 1202, } enum RandomQuality { @@ -163,9 +176,49 @@ enum RandomQuality { NONCE = 2 } +interface ArenaObject { + destroy(): void; +} -abstract class ArenaObject { + +class HashContext implements ArenaObject { + private hashContextPtr: number | undefined; + + constructor() { + this.hashContextPtr = emscAlloc.hash_context_start(); + } + + read(obj: PackedArenaObject): void { + if (!this.hashContextPtr) { + throw Error("assertion failed"); + } + emsc.hash_context_read(this.hashContextPtr, obj.getNative(), obj.size()); + } + + finish(h: HashCode) { + if (!this.hashContextPtr) { + throw Error("assertion failed"); + } + h.alloc(); + emsc.hash_context_finish(this.hashContextPtr, h.getNative()); + } + + destroy(): void { + if (this.hashContextPtr) { + emsc.hash_context_abort(this.hashContextPtr); + } + this.hashContextPtr = undefined; + } +} + + +abstract class MallocArenaObject implements ArenaObject { protected _nativePtr: number | undefined = undefined; + + /** + * Is this a weak reference to the underlying memory? + */ + isWeak = false; arena: Arena; abstract destroy(): void; @@ -192,7 +245,7 @@ abstract class ArenaObject { } free() { - if (this.nativePtr) { + if (this.nativePtr && !this.isWeak) { emsc.free(this.nativePtr); this._nativePtr = undefined; } @@ -270,7 +323,7 @@ class SyncArena extends DefaultArena { super(); } - pub(obj: ArenaObject) { + pub(obj: MallocArenaObject) { super.put(obj); if (!this.isScheduled) { this.schedule(); @@ -295,14 +348,14 @@ let arenaStack: Arena[] = []; arenaStack.push(new SyncArena()); -export class Amount extends ArenaObject { +export class Amount extends MallocArenaObject { constructor(args?: AmountJson, arena?: Arena) { super(arena); if (args) { this.nativePtr = emscAlloc.get_amount(args.value, - 0, - args.fraction, - args.currency); + 0, + args.fraction, + args.currency); } else { this.nativePtr = emscAlloc.get_amount(0, 0, 0, ""); } @@ -399,7 +452,7 @@ export class Amount extends ArenaObject { /** * Count the UTF-8 characters in a JavaScript string. */ -function countBytes(str: string): number { +function countUtf8Bytes(str: string): number { var s = str.length; // JavaScript strings are UTF-16 arrays for (let i = str.length - 1; i >= 0; i--) { @@ -424,7 +477,7 @@ function countBytes(str: string): number { * Managed reference to a contiguous block of memory in the Emscripten heap. * Should contain only data, not pointers. */ -abstract class PackedArenaObject extends ArenaObject { +abstract class PackedArenaObject extends MallocArenaObject { abstract size(): number; constructor(a?: Arena) { @@ -455,12 +508,12 @@ abstract class PackedArenaObject extends ArenaObject { // to the emscripten heap first. let buf = ByteArray.fromString(s); let res = emsc.string_to_data(buf.nativePtr, - s.length, - this.nativePtr, - this.size()); + s.length, + this.nativePtr, + this.size()); buf.destroy(); if (res < 1) { - throw {error: "wrong encoding"}; + throw { error: "wrong encoding" }; } } @@ -581,7 +634,7 @@ function makeFromCrock(decodeFn: (p: number, s: number) => number) { let obj = new this(a); let buf = ByteArray.fromCrock(s); obj.setNative(decodeFn(buf.getNative(), - buf.size())); + buf.size())); buf.destroy(); return obj; } @@ -590,7 +643,7 @@ function makeFromCrock(decodeFn: (p: number, s: number) => number) { } function makeToCrock(encodeFn: (po: number, - ps: number) => number): () => string { + ps: number) => number): () => string { function toCrock() { let ptr = emscAlloc.malloc(PTR_SIZE); let size = emscAlloc.rsa_blinding_key_encode(this.nativePtr, ptr); @@ -658,14 +711,14 @@ export class ByteArray extends PackedArenaObject { static fromString(s: string, a?: Arena): ByteArray { // UTF-8 bytes, including 0-terminator - let terminatedByteLength = countBytes(s) + 1; + let terminatedByteLength = countUtf8Bytes(s) + 1; let hstr = emscAlloc.malloc(terminatedByteLength); Module.stringToUTF8(s, hstr, terminatedByteLength); return new ByteArray(terminatedByteLength, hstr, a); } static fromCrock(s: string, a?: Arena): ByteArray { - let byteLength = countBytes(s); + let byteLength = countUtf8Bytes(s); let hstr = emscAlloc.malloc(byteLength + 1); Module.stringToUTF8(s, hstr, byteLength + 1); let decodedLen = Math.floor((byteLength * 5) / 8); @@ -688,12 +741,12 @@ export class EccSignaturePurpose extends PackedArenaObject { payloadSize: number; constructor(purpose: SignaturePurpose, - payload: PackedArenaObject, - a?: Arena) { + payload: PackedArenaObject, + a?: Arena) { super(a); this.nativePtr = emscAlloc.purpose_create(purpose, - payload.nativePtr, - payload.size()); + payload.nativePtr, + payload.size()); this.payloadSize = payload.size(); } } @@ -798,6 +851,34 @@ export class WithdrawRequestPS extends SignatureStruct { } +interface RefreshMeltCoinAffirmationPS_Args { + session_hash: HashCode; + amount_with_fee: AmountNbo; + melt_fee: AmountNbo; + coin_pub: EddsaPublicKey; +} + +export class RefreshMeltCoinAffirmationPS extends SignatureStruct { + + constructor(w: RefreshMeltCoinAffirmationPS_Args) { + super(w); + } + + purpose() { + return SignaturePurpose.WALLET_COIN_MELT; + } + + fieldTypes() { + return [ + ["session_hash", HashCode], + ["amount_with_fee", AmountNbo], + ["melt_fee", AmountNbo], + ["coin_pub", EddsaPublicKey] + ]; + } +} + + export class AbsoluteTimeNbo extends PackedArenaObject { static fromTalerString(s: string): AbsoluteTimeNbo { let x = new AbsoluteTimeNbo(); @@ -825,7 +906,14 @@ function set64(p: number, n: number) { Module.setValue(p + (7 - i), n & 0xFF, "i8"); n = Math.floor(n / 256); } +} +// XXX: This only works up to 54 bit numbers. +function set32(p: number, n: number) { + for (let i = 0; i < 4; ++i) { + Module.setValue(p + (3 - i), n & 0xFF, "i8"); + n = Math.floor(n / 256); + } } @@ -843,6 +931,20 @@ export class UInt64 extends PackedArenaObject { } +export class UInt32 extends PackedArenaObject { + static fromNumber(n: number): UInt64 { + let x = new UInt32(); + x.alloc(); + set32(x.getNative(), n); + return x; + } + + size() { + return 8; + } +} + + // It's redundant, but more type safe. export interface DepositRequestPS_Args { h_contract: HashCode; @@ -940,7 +1042,7 @@ function makeEncode(encodeFn: any) { } -export class RsaPublicKey extends ArenaObject implements Encodeable { +export class RsaPublicKey extends MallocArenaObject implements Encodeable { static fromCrock: (s: string, a?: Arena) => RsaPublicKey; toCrock() { @@ -965,7 +1067,7 @@ export class EddsaSignature extends PackedArenaObject { } -export class RsaSignature extends ArenaObject implements Encodeable { +export class RsaSignature extends MallocArenaObject implements Encodeable { static fromCrock: (s: string, a?: Arena) => RsaSignature; encode: (arena?: Arena) => ByteArray; @@ -980,21 +1082,21 @@ mixin(RsaSignature, makeEncode(emscAlloc.rsa_signature_encode)); export function rsaBlind(hashCode: HashCode, - blindingKey: RsaBlindingKeySecret, - pkey: RsaPublicKey, - arena?: Arena): ByteArray { + blindingKey: RsaBlindingKeySecret, + pkey: RsaPublicKey, + arena?: Arena): ByteArray { let ptr = emscAlloc.malloc(PTR_SIZE); let s = emscAlloc.rsa_blind(hashCode.nativePtr, - blindingKey.nativePtr, - pkey.nativePtr, - ptr); + blindingKey.nativePtr, + pkey.nativePtr, + ptr); return new ByteArray(s, Module.getValue(ptr, '*'), arena); } export function eddsaSign(purpose: EccSignaturePurpose, - priv: EddsaPrivateKey, - a?: Arena): EddsaSignature { + priv: EddsaPrivateKey, + a?: Arena): EddsaSignature { let sig = new EddsaSignature(a); sig.alloc(); let res = emsc.eddsa_sign(priv.nativePtr, purpose.nativePtr, sig.nativePtr); @@ -1006,14 +1108,14 @@ export function eddsaSign(purpose: EccSignaturePurpose, export function eddsaVerify(purposeNum: number, - verify: EccSignaturePurpose, - sig: EddsaSignature, - pub: EddsaPublicKey, - a?: Arena): boolean { + verify: EccSignaturePurpose, + sig: EddsaSignature, + pub: EddsaPublicKey, + a?: Arena): boolean { let r = emsc.eddsa_verify(purposeNum, - verify.nativePtr, - sig.nativePtr, - pub.nativePtr); + verify.nativePtr, + sig.nativePtr, + pub.nativePtr); if (r === GNUNET_OK) { return true; } @@ -1022,12 +1124,61 @@ export function eddsaVerify(purposeNum: number, export function rsaUnblind(sig: RsaSignature, - bk: RsaBlindingKeySecret, - pk: RsaPublicKey, - a?: Arena): RsaSignature { + bk: RsaBlindingKeySecret, + pk: RsaPublicKey, + a?: Arena): RsaSignature { let x = new RsaSignature(a); x.nativePtr = emscAlloc.rsa_unblind(sig.nativePtr, - bk.nativePtr, - pk.nativePtr); + bk.nativePtr, + pk.nativePtr); return x; } + + +type TransferSecretP = HashCode; + +export function kdf(outLength: number, + salt: PackedArenaObject, + skm: PackedArenaObject, + ...contextChunks: PackedArenaObject[]): ByteArray { + const args: number[] = []; + let out = new ByteArray(outLength); + args.push(out.nativePtr, outLength); + args.push(salt.nativePtr, salt.size()); + args.push(skm.nativePtr, skm.size()); + for (let chunk of contextChunks) { + args.push(chunk.nativePtr, chunk.size()); + } + // end terminator (it's varargs) + args.push(0); + args.push(0); + + let argTypes = args.map(() => "number"); + + const res = Module.ccall("GNUNET_CRYPTO_kdf", "number", argTypes, args); + if (res != GNUNET_OK) { + throw Error("fatal: kdf failed"); + } + + return out; +} + + +export interface FreshCoin { + priv: EddsaPrivateKey; + blindingKey: RsaBlindingKeySecret; +} + +export function setupFreshCoin(secretSeed: TransferSecretP, coinIndex: number): FreshCoin { + let priv = new EddsaPrivateKey(); + priv.isWeak = true; + let blindingKey = new RsaBlindingKeySecret(); + blindingKey.isWeak = true; + + let buf = kdf(priv.size() + blindingKey.size(), UInt32.fromNumber(coinIndex), ByteArray.fromString("taler-coin-derivation")); + + priv.nativePtr = buf.nativePtr; + blindingKey.nativePtr = buf.nativePtr + priv.size(); + + return { priv, blindingKey }; +} \ No newline at end of file diff --git a/lib/wallet/helpers.ts b/lib/wallet/helpers.ts index 5d231fe64..8f65517f7 100644 --- a/lib/wallet/helpers.ts +++ b/lib/wallet/helpers.ts @@ -14,7 +14,6 @@ TALER; see the file COPYING. If not, see */ - /** * Smaller helper functions that do not depend * on the emscripten machinery. @@ -22,7 +21,10 @@ * @author Florian Dold */ +/// + import {AmountJson} from "./types"; +import URI = uri.URI; export function substituteFulfillmentUrl(url: string, vars: any) { url = url.replace("${H_contract}", vars.H_contract); @@ -43,7 +45,7 @@ export function amountToPretty(amount: AmountJson): string { * See http://api.taler.net/wallet.html#general */ export function canonicalizeBaseUrl(url: string) { - let x = new URI(url); + let x: URI = new URI(url); if (!x.protocol()) { x.protocol("https"); } diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts index 5e139a9bc..91b329842 100644 --- a/lib/wallet/types.ts +++ b/lib/wallet/types.ts @@ -25,7 +25,7 @@ * @author Florian Dold */ -import {Checkable} from "./checkable"; +import { Checkable } from "./checkable"; @Checkable.Class export class AmountJson { @@ -120,7 +120,7 @@ export interface IExchangeInfo { } export interface WireInfo { - [type: string]: any; + [type: string]: any; } export interface ReserveCreationInfo { @@ -148,6 +148,53 @@ export interface PreCoin { } +/** + * Ongoing refresh + */ +export interface RefreshSession { + /** + * Public key that's being melted in this session. + */ + meltCoinPub: string; + + /** + * How much of the coin's value is melted away + * with this refresh session? + */ + valueWithFee: AmountJson + + /** + * Signature to confirm the melting. + */ + confirmSig: string; + + /** + * Denominations of the newly requested coins + */ + newDenoms: string[]; + + /** + * Blinded public keys for the requested coins. + */ + newCoinBlanks: string[][]; + + /** + * Blinding factors for the new coins. + */ + newCoinBlindingFactors: string[][]; + + /** + * Private keys for the requested coins. + */ + newCoinPrivs: string[][]; + + /** + * The transfer keys, kappa of them. + */ + transferPubs: string[]; +} + + export interface Reserve { exchange_base_url: string reserve_priv: string; @@ -165,7 +212,6 @@ export interface CoinPaySig { f: AmountJson; } - /** * Coin as stored in the "coins" data store * of the wallet database. @@ -291,7 +337,7 @@ export namespace Amounts { return { currency, value: Number.MAX_SAFE_INTEGER, - fraction: 2**32, + fraction: 2 ** 32, } } @@ -307,7 +353,7 @@ export namespace Amounts { let currency = first.currency; let value = first.value + Math.floor(first.fraction / 1e6); if (value > Number.MAX_SAFE_INTEGER) { - return {amount: getMaxAmount(currency), saturated: true}; + return { amount: getMaxAmount(currency), saturated: true }; } let fraction = first.fraction % 1e6; for (let x of rest) { @@ -318,10 +364,10 @@ export namespace Amounts { value = value + x.value + Math.floor((fraction + x.fraction) / 1e6); fraction = (fraction + x.fraction) % 1e6; if (value > Number.MAX_SAFE_INTEGER) { - return {amount: getMaxAmount(currency), saturated: true}; + return { amount: getMaxAmount(currency), saturated: true }; } } - return {amount: {currency, value, fraction}, saturated: false}; + return { amount: { currency, value, fraction }, saturated: false }; } @@ -334,7 +380,7 @@ export namespace Amounts { let fraction = a.fraction; if (fraction < b.fraction) { if (value < 1) { - return {amount: {currency, value: 0, fraction: 0}, saturated: true}; + return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; } value--; fraction += 1e6; @@ -342,10 +388,10 @@ export namespace Amounts { console.assert(fraction >= b.fraction); fraction -= b.fraction; if (value < b.value) { - return {amount: {currency, value: 0, fraction: 0}, saturated: true}; + return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; } value -= b.value; - return {amount: {currency, value, fraction}, saturated: false}; + return { amount: { currency, value, fraction }, saturated: false }; } export function cmp(a: AmountJson, b: AmountJson): number { diff --git a/pages/tree.tsx b/pages/tree.tsx index acc470216..b1c22b9f8 100644 --- a/pages/tree.tsx +++ b/pages/tree.tsx @@ -84,6 +84,30 @@ interface CoinViewProps { coin: Coin; } +interface RefreshDialogProps { + coin: Coin; +} + +class RefreshDialog extends ImplicitStateComponent { + refreshRequested = this.makeState(false); + render(): JSX.Element { + if (!this.refreshRequested()) { + return ( +
+ +
+ ); + } + return ( +
+ Refresh amount: + + +
+ ); + } +} + class CoinView extends preact.Component { render() { let c = this.props.coin; @@ -94,6 +118,7 @@ class CoinView extends preact.Component {
  • Current amount: {prettyAmount(c.currentAmount)}
  • Denomination: {abbrev(c.denomPub, 20)}
  • Suspended: {(c.suspended || false).toString()}
  • +
  • );