UNPKG

@bsv/spv-wallet-js-client

Version:

TypeScript library for connecting to a SPV Wallet server

1,337 lines (1,316 loc) 54.4 kB
'use strict'; require('cross-fetch/polyfill'); var sdk = require('@bsv/sdk'); var EventEmitter = require('node:events'); class SpvWalletError extends Error { constructor(message) { super(message); this.name = this.constructor.name; if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } } } class ErrorInvalidClientOptions extends SpvWalletError { constructor(logger, options) { super('Invalid options. None of xPub, xPriv nor accessKey is set'); logger.debug('Invalid options: ', options); } } class ErrorInvalidAdminClientOptions extends SpvWalletError { constructor(logger, options) { super('Invalid options. No adminKey set'); logger.debug('Invalid options: ', options); } } class ErrorNoXPrivToSignTransaction extends SpvWalletError { constructor() { super('Cannot sign transaction without an xPriv'); } } class ErrorClientInitNoXpriv extends SpvWalletError { constructor() { super('Init client with xPriv first'); } } class ErrorTxIdsDontMatchToDraft extends SpvWalletError { constructor(logger, input, index, draftInput) { super('Input tx ids do not match in draft and transaction hex'); logger.debug('The input: ', input, 'Tx index: ', index, 'The draft', draftInput); this.input = input; this.draftInput = draftInput; } } class ErrorNoAdminKey extends SpvWalletError { constructor() { super('Admin key has not been set. Cannot do admin queries.'); } } class ErrorResponse extends SpvWalletError { constructor(logger, response, content) { super('Received error response'); logger.debug('StatusCode:', response.status, 'Error response:', response, 'The content:', content); this.response = response; this.content = content; } } class ErrorWrongHex extends SpvWalletError { constructor(wrongHex) { super('Provided hexHash is not a valid hex string'); this.value = wrongHex; } } class ErrorNoXPrivToGenerateTOTP extends SpvWalletError { constructor() { super('Cannot generate TOTP without an xPrivKey set'); } } class ErrorNoXPrivToValidateTOTP extends SpvWalletError { constructor() { super('Cannot validate TOTP without an xPrivKey set'); } } class ErrorWrongTOTP extends SpvWalletError { constructor() { super('TOTP is invalid'); } } class ErrorSyncMerkleRootsTimeout extends SpvWalletError { constructor() { super('SyncMerkleRoots operation timed out'); } } class ErrorStaleLastEvaluatedKey extends SpvWalletError { constructor() { super('The last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.'); } } const maxInt32 = 2147483648 - 1; // 0x80000000 // RandomHex returns a random hex string and error const RandomHex = function (n) { return sdk.Utils.toHex(sdk.Random(n)); }; // ToHash returns a sha256 hash of the string const ToHash = function (string) { const sha256 = sdk.Hash.sha256(string); return sdk.Utils.toHex(sha256); }; // isHex returns whether the given hex string a valid hex string is const isHex = function (hexString) { return !!hexString.match(/^[0-9a-f]*$/i); }; // getChildNumsFromHex get an array of numbers from the hex string const getChildNumsFromHex = function (hexHash) { if (!isHex(hexHash)) { throw new ErrorWrongHex(hexHash); } const strLen = hexHash.length; const size = 8; const splitLength = Math.ceil(strLen / size); const childNums = []; for (let i = 0; i < splitLength; i++) { const start = i * size; let stop = start + size; if (stop > strLen) { stop = strLen; } let num = Number('0x' + hexHash.substring(start, stop)); if (num > maxInt32) { num = num - maxInt32; } childNums.push(num); } return childNums; }; // deriveChildKeyFromHex derive the child extended key from the hex string const deriveChildKeyFromHex = function (hdKey, hexHash) { return deriveHDChildKeyFromHex(hdKey, hexHash); }; const deriveHDChildKeyFromHex = function (hdKey, hexHash) { let childKey = hdKey; const childNums = getChildNumsFromHex(hexHash); childNums.forEach((childNum) => { childKey = childKey.deriveChild(childNum); }); return childKey; }; const generateKeys = function () { const mnemonic = sdk.Mnemonic.fromRandom(); return getKeysFromMnemonic(mnemonic.toString()); }; const getKeysFromMnemonic = function (mnemonicStr) { const mnemonic = sdk.Mnemonic.fromString(mnemonicStr); const seed = mnemonic.toSeed(); const hdWallet = new sdk.HD().fromSeed(seed); return { xPriv: () => hdWallet.toString(), mnemonic: mnemonic.toString(), xPub: { toString() { return hdWallet.toPublic().toString(); }, }, }; }; const getKeysFromString = function (privateKey) { let hdWallet = new sdk.HD().fromString(privateKey); return { xPriv: () => hdWallet.privKey.toString(), xPub: { toString() { return hdWallet.toPublic().toString(); }, }, }; }; const signMessage = function (message, privateKey) { const messageBuf = sdk.Utils.toArray(message); const hash = sdk.BSM.magicHash(messageBuf); const bnh = new sdk.BigNumber(hash); const signature = sdk.ECDSA.sign(bnh, privateKey, true); const recovery = signature.CalculateRecoveryFactor(privateKey.toPublicKey(), bnh); return signature.toCompact(recovery, true, 'base64'); }; // AuthHeader is the header to use for authentication (raw xPub) const AuthHeader = 'x-auth-xpub'; // AuthAccessKey is the header to use for access key authentication (access public key) const AuthAccessKey = 'x-auth-key'; // AuthSignature is the given signature (body + timestamp) const AuthSignature = 'x-auth-signature'; // AuthHeaderHash hash of the body coming from the request const AuthHeaderHash = 'x-auth-hash'; // AuthHeaderNonce random nonce for the request const AuthHeaderNonce = 'x-auth-nonce'; // AuthHeaderTime the time of the request, only valid for 30 seconds const AuthHeaderTime = 'x-auth-time'; const setSignature = function (headers, signingKey, bodyString) { // Create the signature const authData = createSignature(signingKey, bodyString); // Set the auth header if (authData.xPub) { headers[AuthHeader] = authData.xPub; } else if (authData.accessKey) { headers[AuthAccessKey] = authData.accessKey; } return setSignatureHeaders(headers, authData); }; const setSignatureHeaders = function (headers, authData) { // Create the auth header hash if (authData.AuthHash) { headers[AuthHeaderHash] = authData.AuthHash; } // Set the nonce if (authData.AuthNonce) { headers[AuthHeaderNonce] = authData.AuthNonce; } // Set the time if (authData.AuthTime) { headers[AuthHeaderTime] = authData.AuthTime.toString(); } // Set the signature if (authData.Signature) { headers[AuthSignature] = authData.Signature; } return headers; }; const createSignature = function (signingKey, bodyString) { const payload = {}; // x-auth-nonce is a random unique string to seed the signing message // this can be checked server side to make sure the request is not being replayed payload.AuthNonce = RandomHex(32); let privateKey; if (isHDWallet(signingKey)) { // Get the xPub payload.xPub = signingKey.toPublic().toString(); payload.accessKey = undefined; // Derive the address for signing const hdWallet = deriveHDChildKeyFromHex(signingKey, payload.AuthNonce); privateKey = hdWallet.privKey; } else { privateKey = signingKey; payload.xPub = undefined; payload.accessKey = privateKey.toPublicKey().toString(); } return createSignatureCommon(payload, bodyString, privateKey); }; const createSignatureCommon = function (payload, bodyString, privateKey) { // Create the auth header hash payload.AuthHash = ToHash(bodyString); // x-auth-time is the current time and makes sure a request can not be sent after 30 secs payload.AuthTime = +new Date(); let key = payload.xPub; if (!key && payload.accessKey) { key = payload.accessKey; } // Signature, using bitcoin signMessage const message = getSigningMessage(key || '', payload); payload.Signature = signMessage(message, privateKey); return payload; }; // getSigningMessage will build the signing message string const getSigningMessage = function (xPub, auth) { return `${xPub}${auth.AuthHash}${auth.AuthNonce}${auth.AuthTime}`; }; const isHDWallet = (key) => { return key != null && key instanceof sdk.HD; }; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; class EventsMap { constructor(logger) { this.registered = new Map(); this.logger = logger; } store(eventName, handler) { var _a; if (!this.registered.has(eventName)) { this.registered.set(eventName, new EventEmitter()); } (_a = this.registered.get(eventName)) === null || _a === void 0 ? void 0 : _a.on(eventName, (event) => __awaiter(this, void 0, void 0, function* () { try { yield handler(event); } catch (error) { this.logger.error(`Error in handler for event ${eventName}`, error); } })); } load(eventName) { return this.registered.get(eventName); } } class WebhookManager { constructor(subscriber, url, options = {}) { this.url = url; this.subscriber = subscriber; this.options = { tokenValue: options.tokenValue || '', tokenHeader: options.tokenHeader || '', }; this.handlers = new EventsMap(subscriber.logger); } subscribe() { return this.subscriber.subscribeWebhook(this.url, this.options.tokenHeader, this.options.tokenValue); } unsubscribe() { return this.subscriber.unsubscribeWebhook(this.url); } handleIncomingEvents(httpHandler) { return __awaiter(this, void 0, void 0, function* () { const token = httpHandler.getHeader(this.options.tokenHeader); if (this.options.tokenHeader !== '' && token !== this.options.tokenValue) { httpHandler.handleResponse(401, { message: 'Unauthorized' }); return; } try { const events = httpHandler.getBody(); events.forEach((event) => { const handler = this.handlers.load(event.type); if (handler) { handler.emit(event.type, event.content); } else { this.subscriber.logger.debug(`No handler registered for event type: ${event.type}`); } }); httpHandler.handleResponse(200); } catch (error) { if (error instanceof Error) { this.subscriber.logger.error(error.message); } else { this.subscriber.logger.error('Unknown error during event handling'); } httpHandler.handleResponse(500, { message: 'error processing events' }); } }); } registerHandler(eventName, handlerFunction) { this.handlers.store(eventName, handlerFunction); } } const isLogger = (loggerConfig) => { const logger = loggerConfig; return (typeof logger.debug === 'function' && typeof logger.info === 'function' && typeof logger.warn === 'function' && typeof logger.error === 'function'); }; const nop = () => { }; const defaultLogger = { level: 'info' }; const levelToNumber = (level) => { switch (level) { case 'debug': return 1; case 'info': return 2; case 'warn': return 3; case 'error': return 4; case 'disabled': return 5; default: return 2; } }; const makeLogger = (loggerConfig) => { if (isLogger(loggerConfig)) { return loggerConfig; } else { const { level } = loggerConfig; const levelAsNumber = levelToNumber(level); return { debug: levelAsNumber <= 1 ? console.debug : nop, info: levelAsNumber <= 2 ? console.info : nop, warn: levelAsNumber <= 3 ? console.warn : nop, error: levelAsNumber <= 4 ? console.error : nop, }; } }; class HttpClient { constructor(logger, url, key, adminKey) { if (key != null) { if (typeof key === 'string') { //only xPub can be a string this.xPubString = key; } else { this.signingKey = key; } } if (adminKey) { this.adminKey = new sdk.HD().fromString(adminKey); } this.logger = logger; this.baseUrl = url.endsWith('/') ? url : url + '/'; //make sure the url ends with a '/' } adminRequest(path_1) { return __awaiter(this, arguments, void 0, function* (path, method = 'GET', payload = null) { if (!this.hasAdminKey()) { throw new ErrorNoAdminKey(); } this.logger.debug('Making request as admin on', method, path); return this.makeRequest(path, method, payload, this.adminKey); }); } request(path_1) { return __awaiter(this, arguments, void 0, function* (path, method = 'GET', payload = null) { this.logger.debug('Making request on', method, path); return this.makeRequest(path, method, payload, this.signingKey); }); } hasAdminKey() { return this.adminKey != null; } makeRequest(path, method, payload, currentSigningKey) { return __awaiter(this, void 0, void 0, function* () { const json = payload ? JSON.stringify(payload) : null; let headers = { 'content-type': 'application/json' }; if (currentSigningKey != null) { headers = setSignature(headers, currentSigningKey, json || ''); } else if (this.xPubString) { headers[AuthHeader] = this.xPubString; } const res = yield globalThis.fetch(this.prepareUrl(path), { method, headers, body: json, }); if (res.ok) { const contentType = res.headers.get('Content-Type'); if (contentType && contentType.includes('application/json')) { return res.json(); } return res.text(); } else { const rawContent = yield res.text(); throw new ErrorResponse(this.logger, res, rawContent); } }); } prepareUrl(path) { path = path.startsWith('/') ? path.substring(1) : path; return this.baseUrl + path; } } function addToURLSearchParams(urlSP, params, parentKey) { Object.entries(params).forEach(([key, value]) => { if (!value) { return; } const newKey = parentKey ? `${parentKey}[${key}]` : key; if (typeof value === 'object' && !Array.isArray(value)) { // Recursively flatten nested objects addToURLSearchParams(urlSP, value, newKey); } else if (Array.isArray(value)) { value.forEach((element) => { const arrayKey = `${newKey}[]`; urlSP.append(arrayKey, element); }); } else { urlSP.append(newKey, String(value)); // ensure value is a string } }); } function buildQueryPath({ filter, metadata, page: queryParams }) { const allParams = new URLSearchParams(); if (queryParams) { addToURLSearchParams(allParams, queryParams); } if (filter) { addToURLSearchParams(allParams, filter); } if (metadata) { addToURLSearchParams(allParams, metadata, 'metadata'); } const params = new URLSearchParams(allParams); const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } /** * SPVWalletAdminAPI class for handling administrative operations * * @class SPVWalletAdminAPI */ class SPVWalletAdminAPI { /** * Creates a new instance of SPVWalletAdminAPI * * @param {string} serverUrl - The base URL of the SPV Wallet server * @param {AdminClientOptions} options - Configuration options including adminKey * @param {LoggerConfig} loggerConfig - Logger configuration (optional) */ constructor(serverUrl, options, loggerConfig = defaultLogger) { serverUrl = this.ensureSuffix(serverUrl, '/api/v1'); this.logger = makeLogger(loggerConfig); this.http = this.makeRequester(options, serverUrl); } ensureSuffix(serverUrl, suffix) { return serverUrl.endsWith(suffix) ? serverUrl : serverUrl + suffix; } makeRequester(options, serverUrl) { if (options.adminKey) { this.logger.info('Using adminKey to sign admin requests'); return new HttpClient(this.logger, serverUrl, undefined, options.adminKey); } throw new ErrorInvalidAdminClientOptions(this.logger, options); } /** * Check if the admin key is valid * * @returns {Promise<boolean>} True if admin key is valid */ status() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/status'); }); } /** * Get server statistics * * @returns {Promise<AdminStats>} Server statistics */ stats() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/stats'); }); } /** * Get a list of all access keys in the system * * @param {AdminAccessKeyFilter} conditions - Filter conditions for access keys * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<AccessKey>>} List of access keys */ accessKeys(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/users/keys'; const queryString = buildQueryPath({ metadata, filter: conditions, page: params, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Get a list of all contacts in the system * * @param {AdminContactFilter} conditions - Filter conditions for contacts * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<Contact>>} List of contacts */ contacts(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/contacts'; const queryString = buildQueryPath({ metadata, filter: conditions, page: params, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Update contact information * * @param {string} id - Contact ID * @param {string} fullName - New full name for the contact * @param {Metadata} metadata - Updated metadata * @returns {Promise<Contact>} Updated contact information */ contactUpdate(id, fullName, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/contacts/${id}`, 'PUT', { fullName, metadata }); }); } /** * Delete a contact * * @param {string} id - ID of the contact to delete * @returns {Promise<void>} */ deleteContact(id) { return __awaiter(this, void 0, void 0, function* () { yield this.http.adminRequest(`admin/contacts/${id}`, 'DELETE', {}); }); } /** * Accept a contact invitation * * @param {string} id - ID of the invitation to accept * @returns {Promise<Contact>} The accepted contact */ acceptInvitation(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/invitations/${id}`, 'POST', {}); }); } /** * Reject a contact invitation * * @param {string} id - ID of the invitation to reject * @returns {Promise<void>} */ rejectInvitation(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/invitations/${id}`, 'DELETE', {}); }); } /** * Get a transaction by ID * * @param {string} id - Transaction ID * @returns {Promise<AdminTx>} Transaction details */ transaction(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/transactions/${id}`, 'GET'); }); } /** * Get a list of all transactions * * @param {TransactionFilter} conditions - Filter conditions for transactions * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<AdminTx>>} List of transactions */ transactions(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/transactions'; const queryString = buildQueryPath({ filter: conditions, metadata, page: params, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Get a list of all UTXOs * * @param {AdminUtxoFilter} conditions - Filter conditions for UTXOs * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<Utxo>>} List of UTXOs */ utxos(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/utxos'; const queryString = buildQueryPath({ filter: conditions, metadata, page: params, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Get a list of all xPubs * * @param {XpubFilter} conditions - Filter conditions for xPubs * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<XPub>>} List of xPubs */ xPubs(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/users'; const queryString = buildQueryPath({ filter: conditions, metadata, page: params, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Register a new xPub * * @param {string} rawXPub - Raw xPub key to register * @param {Metadata} metadata - Metadata for the xPub * @returns {Promise<XPub>} Registered xPub information */ createXPub(rawXPub, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/users', 'POST', { key: rawXPub, metadata, }); }); } /** * Get a paymail by address * * @param {string} id - Paymail ID or address * @returns {Promise<PaymailAddress>} Paymail information */ paymail(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/paymails/${id}`, 'GET'); }); } /** * Get a list of all paymails * * @param {AdminPaymailFilter} conditions - Filter conditions for paymails * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} params - Pagination parameters * @returns {Promise<PageModel<PaymailAddress>>} List of paymail addresses */ paymails(conditions, metadata, params) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'admin/paymails'; const queryString = buildQueryPath({ metadata, page: params, filter: conditions, }); return yield this.http.adminRequest(`${basePath}${queryString}`, 'GET'); }); } /** * Create a new paymail * * @param {string} rawXPub - Raw xpub to register the paymail to * @param {string} address - Paymail address (e.g., alias@domain.com) * @param {string} publicName - Public name for the paymail * @param {string} avatar - Avatar URL * @param {Metadata} metadata - Additional metadata * @returns {Promise<PaymailAddress>} Created paymail address */ createPaymail(rawXPub, address, publicName, avatar, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/paymails', 'POST', { metadata, key: rawXPub, address, publicName, avatar, }); }); } /** * Delete a paymail * @param {string} id - Paymail Id of user to be deleted */ deletePaymail(id) { return __awaiter(this, void 0, void 0, function* () { yield this.http.adminRequest(`admin/paymails/${id}`, 'DELETE'); }); } /** * Get webhook subscriptions * * @returns {Promise<Webhook[]>} List of webhook subscriptions */ webhooks() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/webhooks/subscriptions', 'GET'); }); } /** * Subscribe to webhook * * @param {string} url - Webhook URL * @param {string} tokenHeader - Header name for the authentication token * @param {string} tokenValue - Value of the authentication token * @returns {Promise<void>} */ subscribeWebhook(url, tokenHeader, tokenValue) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/webhooks/subscriptions', 'POST', { url, tokenHeader, tokenValue }); }); } /** * Unsubscribe from webhook * * @param {string} url - URL of the webhook to unsubscribe * @returns {Promise<void>} */ unsubscribeWebhook(url) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('admin/webhooks/subscriptions', 'DELETE', { url }); }); } /** * Create new contact * * @param {string} contactPaymail - Paymail address for the new contact * @param {NewContact} newContact - Contact information * @returns {Promise<Contact>} Created contact */ createContact(contactPaymail, newContact) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest(`admin/contacts/${contactPaymail}`, 'POST', newContact); }); } /** * Confirm contacts * * @param {string} paymailA - First contact's paymail * @param {string} paymailB - Second contact's paymail * @returns {Promise<void>} */ confirmContacts(paymailA, paymailB) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('/admin/contacts/confirmations', 'POST', { paymailA, paymailB }); }); } /** * Get shared configuration * * @returns {Promise<SharedConfig>} Shared configuration settings */ sharedConfig() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.adminRequest('configs/shared', 'GET'); }); } } const DEFAULT_TOTP_PERIOD = 30; const DEFAULT_TOTP_DIGITS = 2; /* Basic flow: Alice generates passcodeForBob with (sharedSecret+(contact.Paymail as bobPaymail)) Alice sends passcodeForBob to Bob (e.g. via email) Bob validates passcodeForBob with (sharedSecret+(requesterPaymail as bobPaymail)) The (sharedSecret+paymail) is a "directedSecret". This ensures that passcodeForBob-from-Alice != passcodeForAlice-from-Bob. The flow looks the same for Bob generating passcodeForAlice. */ /** * Generates a TOTP for a given contact * * @param clientXPriv - The client xpriv * @param contact - The Contact * @param period - The TOTP period (default: 30) * @param digits - The number of TOTP digits (default: 2) * @returns The generated TOTP as a string */ const generateTotpForContact = (clientXPriv, contact, period = DEFAULT_TOTP_PERIOD, digits = DEFAULT_TOTP_DIGITS) => { const sharedSecret = makeSharedSecret(contact, clientXPriv); let secret = directedSecret(sharedSecret, contact.paymail); return sdk.TOTP.generate(secret, getTotpOps(period, digits)); }; /** * Validates a TOTP for a given contact * * @param clientXPriv - The client xpriv * @param generatorContact - The Contact of the person who generated the TOTP * @param passcode - The TOTP passcode to validate * @param validatorPaymail - The paymail of the person who is validating the TOTP * @param period - The TOTP period (default: 30) * @param digits - The number of TOTP digits (default: 2) * @returns A boolean indicating whether the TOTP is valid */ const validateTotpForContact = (clientXPriv, generatorContact, passcode, validatorPaymail, period = DEFAULT_TOTP_PERIOD, digits = DEFAULT_TOTP_DIGITS) => { const sharedSecret = makeSharedSecret(generatorContact, clientXPriv); const secret = directedSecret(sharedSecret, validatorPaymail); return sdk.TOTP.validate(secret, passcode, getTotpOps(period, digits)); }; const getTotpOps = (period, digits) => ({ digits, period, algorithm: 'SHA-1', }); const makeSharedSecret = (contact, clientXPriv) => { const xprivKey = new sdk.HD().fromString(clientXPriv.toString()); const pubKey = sdk.PublicKey.fromString(contact.pubKey); // PKI derivation path: m/0/0/0 // NOTICE: we currently do not support PKI rotation; however, adjustments will be made if and when we decide to implement it const hd = xprivKey.derive('m/0/0/0'); const privKey = hd.privKey; const ss = privKey.deriveSharedSecret(pubKey); return ss.getX().toHex(32); }; const directedSecret = (sharedSecret, paymail) => { const sharedSecretDigest = sdk.Utils.toArray(sharedSecret, 'hex'); const paymailDigest = sdk.Utils.toArray(paymail, 'utf8'); const concatenated = [...sharedSecretDigest, ...paymailDigest]; return concatenated; }; /** * SPVWalletUserAPI class for handling user-specific operations * * @class SPVWalletUserAPI */ class SPVWalletUserAPI { /** * Creates a new instance of SPVWalletUserAPI * * @param {string} serverUrl - The base URL of the SPV Wallet server * @param {ClientOptions} options - Configuration options including xPub, xPriv, or accessKey * @param {LoggerConfig} loggerConfig - Logger configuration (optional) */ constructor(serverUrl, options, loggerConfig = defaultLogger) { serverUrl = this.ensureSuffix(serverUrl, '/api/v1'); this.logger = makeLogger(loggerConfig); this.http = this.makeRequester(options, serverUrl); } ensureSuffix(serverUrl, suffix) { return serverUrl.endsWith(suffix) ? serverUrl : serverUrl + suffix; } makeRequester(options, serverUrl) { if (options.xPub) { this.logger.info('Using XPub. SendToRecipients function will not be available.'); return new HttpClient(this.logger, serverUrl, options.xPub); } if (options.xPriv) { this.logger.info('Using xPriv to sign requests'); this.xPriv = new sdk.HD().fromString(options.xPriv); return new HttpClient(this.logger, serverUrl, this.xPriv); } if (options.accessKey) { this.logger.info('Using accessKey to sign requests. SendToRecipients will not be available.'); const signingKey = sdk.PrivateKey.fromString(options.accessKey, 'hex'); return new HttpClient(this.logger, serverUrl, signingKey); } throw new ErrorInvalidClientOptions(this.logger, options); } /** * Get a list of all contacts for the current user * * @param {ContactFilter} conditions - Key value object to use to filter the documents * @param {Metadata} metadata - Key value object to use to filter the documents by the metadata * @param {QueryPageParams} queryParams - Database query parameters for page, page size and sorting * @returns {Promise<PageModel<Contact>>} List of contacts matching the criteria */ contacts(conditions, metadata, queryParams) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'contacts'; const queryString = buildQueryPath({ filter: conditions, metadata: metadata, page: queryParams, }); return yield this.http.request(`${basePath}${queryString}`, 'GET'); }); } /** * Get a single contact by paymail address * * @param {string} paymail - Paymail address of the contact * @returns {Promise<Contact>} Contact information */ contactWithPaymail(paymail) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`contacts/${paymail}`, 'GET'); }); } /** * Update or insert a contact * * @param {string} paymail - Contact's paymail address * @param {string} fullName - Full name of the contact * @param {string} requesterPaymail - Paymail of the requester * @param {Metadata} metadata - Additional metadata for the contact * @returns {Promise<Contact>} Updated or created contact */ upsertContact(paymail, fullName, requesterPaymail, metadata) { return __awaiter(this, void 0, void 0, function* () { let payload = { fullName, requesterPaymail, metadata }; if (requesterPaymail !== '') { payload['requesterPaymail'] = requesterPaymail; } return yield this.http.request(`contacts/${paymail}`, 'PUT', payload); }); } /** * Remove a contact by paymail address * * @param {string} paymail - Paymail address of the contact to remove * @returns {Promise<void>} */ removeContact(paymail) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`contacts/${paymail}`, 'DELETE'); }); } /** * Confirm a contact by validating their TOTP passcode * * @param {Contact} contact - Contact to confirm * @param {string} passcode - TOTP passcode to validate * @param {string} requesterPaymail - Paymail of the person requesting confirmation * @param {number} [period=DEFAULT_TOTP_PERIOD] - TOTP period in seconds * @param {number} [digits=DEFAULT_TOTP_DIGITS] - Number of digits in TOTP * @returns {Promise<boolean>} True if confirmation successful * @throws {ErrorWrongTOTP} If TOTP validation fails */ confirmContact(contact_1, passcode_1, requesterPaymail_1) { return __awaiter(this, arguments, void 0, function* (contact, passcode, requesterPaymail, period = DEFAULT_TOTP_PERIOD, digits = DEFAULT_TOTP_DIGITS) { const isTotpValid = this.validateTotpForContact(contact, passcode, requesterPaymail, period, digits); if (!isTotpValid) { throw new ErrorWrongTOTP(); } return yield this.http.request(`contacts/${requesterPaymail}/confirmation`, 'POST'); }); } /** * Remove confirmation status from a contact * * @param {string} paymail - Paymail address of the contact to unconfirm * @returns {Promise<void>} */ unconfirmContact(paymail) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`contacts/${paymail}/confirmation`, 'DELETE'); }); } /** * Accept a contact invitation * * @param {string} paymail - Paymail address of the contact who sent the invitation * @returns {Promise<void>} */ acceptInvitation(paymail) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`invitations/${paymail}/contacts`, 'POST'); }); } /** * Reject a contact invitation * * @param {string} paymail - Paymail address of the contact whose invitation to reject * @returns {Promise<void>} */ rejectInvitation(paymail) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`invitations/${paymail}/contacts`, 'DELETE'); }); } /** * Get shared configuration settings * * @returns {Promise<SharedConfig>} Shared configuration object */ sharedConfig() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('configs/shared', 'GET'); }); } /** * Draft a new transaction * * @param {DraftTransactionConfig} config - Configuration for the draft transaction * @param {Metadata} metadata - Additional metadata for the transaction * @returns {Promise<DraftTx>} The draft transaction */ draftTransaction(config, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('transactions/drafts', 'POST', { config: config, metadata, }); }); } /** * Record a transaction in the system * * @param {string} hex - Transaction hex * @param {string} referenceId - Reference ID (usually draft transaction ID) * @param {Metadata} metadata - Additional metadata for the transaction * @returns {Promise<Tx>} The recorded transaction */ recordTransaction(hex, referenceId, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('transactions', 'POST', { hex, referenceId, metadata, }); }); } /** * Update transaction metadata * * @param {string} txId - Transaction ID * @param {Metadata} metadata - New metadata to update * @returns {Promise<Tx>} Updated transaction */ updateTransactionMetadata(txId, metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`transactions/${txId}`, 'PATCH', { metadata, }); }); } /** * Get a list of transactions * * @param {TransactionFilter} conditions - Filter conditions * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} queryParams - Pagination parameters * @returns {Promise<PageModel<Tx>>} List of transactions */ transactions(conditions, metadata, queryParams) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'transactions'; const queryString = buildQueryPath({ filter: conditions, metadata: metadata, page: queryParams, }); return yield this.http.request(`${basePath}${queryString}`, 'GET'); }); } /** * Get transaction by ID * * @param {string} id - Transaction ID * @returns {Promise<Tx>} Transaction details */ transaction(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`transactions/${id}`, 'GET'); }); } /** * Finalize a draft transaction by signing it * * @param {DraftTx} draft - Draft transaction to finalize * @returns {Promise<string>} Signed transaction hex * @throws {ErrorNoXPrivToSignTransaction} If xPriv is not available * @throws {ErrorTxIdsDontMatchToDraft} If transaction IDs don't match */ finalizeTransaction(draft) { return __awaiter(this, void 0, void 0, function* () { var _a; if (!this.xPriv) { throw new ErrorNoXPrivToSignTransaction(); } const txDraft = sdk.Transaction.fromHex(draft.hex); (_a = draft.configuration.inputs) === null || _a === void 0 ? void 0 : _a.forEach((input, index) => { const { destination } = input; if (destination == null) { throw new Error('Unexpected input that does not contain destination which is required for signing'); } let hdWallet = this.xPriv.deriveChild(destination.chain).deriveChild(destination.num); if (destination.paymailExternalDerivationNum != null) { hdWallet = hdWallet.deriveChild(destination.paymailExternalDerivationNum); } if (input.transactionId != txDraft.inputs[index].sourceTXID || input.outputIndex != txDraft.inputs[index].sourceOutputIndex) { throw new ErrorTxIdsDontMatchToDraft(this.logger, input, index, txDraft.inputs[index]); } txDraft.inputs[index].unlockingScriptTemplate = new sdk.P2PKH().unlock(hdWallet.privKey, 'single', false, input.satoshis, new sdk.P2PKH().lock(destination.address)); txDraft.inputs[index].sourceOutputIndex = input.outputIndex; txDraft.inputs[index].sourceTXID = input.transactionId; }); yield txDraft.sign(); return txDraft.toHex(); }); } /** * Send to recipients (combines draft, sign, and record) * * @param {DraftTransactionConfig} config - Transaction configuration * @param {Metadata} metadata - Transaction metadata * @returns {Promise<Tx>} The final transaction */ sendToRecipients(config, metadata) { return __awaiter(this, void 0, void 0, function* () { const draft = yield this.draftTransaction(config, metadata); const finalized = yield this.finalizeTransaction(draft); return this.recordTransaction(finalized, draft.id, metadata); }); } /** * Get current user's xPub information * * @returns {Promise<User>} User information */ xPub() { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('users/current'); }); } /** * Update xPub metadata * * @param {Metadata} metadata - New metadata to update * @returns {Promise<User>} Updated user information */ updateXPubMetadata(metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('users/current', 'PATCH', { metadata }); }); } /** * Generate a new access key * * @param {Metadata} metadata - Metadata for the new access key * @returns {Promise<AccessKey>} Generated access key */ generateAccessKey(metadata) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request('users/current/keys', 'POST', { metadata }); }); } /** * Get a list of access keys * * @param {AccessKeyFilter} conditions - Filter conditions for access keys * @param {QueryPageParams} queryParams - Pagination parameters * @returns {Promise<PageModel<AccessKey>>} List of access keys */ accessKeys(conditions, queryParams) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'users/current/keys'; const queryString = buildQueryPath({ filter: conditions, metadata: {}, page: queryParams, }); return yield this.http.request(`${basePath}${queryString}`, 'GET'); }); } /** * Get a specific access key by ID * * @param {string} id - Access key ID * @returns {Promise<AccessKey>} Access key details */ accessKey(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`users/current/keys/${id}`); }); } /** * Revoke an access key * * @param {string} id - ID of the access key to revoke * @returns {Promise<void>} */ revokeAccessKey(id) { return __awaiter(this, void 0, void 0, function* () { return yield this.http.request(`users/current/keys/${id}`, 'DELETE'); }); } /** * Get a list of UTXOs * * @param {UtxoFilter} conditions - Filter conditions for UTXOs * @param {Metadata} metadata - Metadata filter * @param {QueryPageParams} queryParams - Pagination parameters * @returns {Promise<PageModel<Utxo>>} List of UTXOs */ utxos(conditions, metadata, queryParams) { return __awaiter(this, void 0, void 0, function* () { const basePath = 'utxos'; const queryString = buildQueryPath({ filter: conditions, metadata: metadata, page: queryParams, }); return yield this.http.request(`${basePath}${queryString}`, 'GET'); }); } /** * Get merkle roots * * @param {string} [lastEvaluatedKey] - Last evaluated key for pagination * @returns {Promise<ExclusiveStartKeyPage<MerkleRoot[]>>} Page of merkle roots */ merkleRoots(lastEvaluatedKey) { return __awaiter(this, void 0, void 0, function* () { const requestPath = 'merkleroots'; const lastEvaluatedKeyQuery = lastEvaluatedKey ? `?lastEvaluatedKey=${lastEvaluatedKey}` : ''; return yield this.http.request(`${requestPath}${lastEvaluatedKeyQuery}`, 'GET'); }); } /** * Sync merkle roots from the client db to the last known block * * @param {MerkleRootsRepository} repo - Repository interface for merkle root operations * @param {number} [timeoutMs] - Optional timeout in milliseconds * @throws {ErrorSyncMerkleRootsTimeout} When the sync operation times out * @throws {ErrorStaleLastEvaluatedKey} When the last evaluated key becomes stale * @returns {Promise<void>} */ syncMerkleRoots(repo, timeoutMs) { return __awaiter(this, void 0, void 0, function* () { const startTime = Date.now(); let merkleRootsResponse; let lastEvaluatedKey = yield repo.getLastMerkleRoot(); let previousLastEvaluatedKey = lastEvaluatedKey || null; const requestPath = 'merkleroots'; let lastEvaluatedKeyQuery = ''; if (lastEvaluatedKey) { lastEvaluatedKeyQuery = `?lastEvaluatedKey=${lastEvaluatedKey}`;