From 24c46d956734de750e9248914afcaa93a8be5ead Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 01/24] Enable the namespace re-export transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `typechain` emits `export * as factories from './factories'`, and the React Native preset does not transform namespace re-exports. The plugin was declared but never enabled, so `TransactionListTop` failed to parse the moment `src/plugins/contracts` existed — which it does after any `npm install`, since `prepare` generates it. Metro needs that transform as much as jest does, so it belongs in the shared config. --- babel.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/babel.config.js b/babel.config.js index 9adf2e4f163..78781a0afbd 100644 --- a/babel.config.js +++ b/babel.config.js @@ -4,6 +4,10 @@ module.exports = function (api) { return { presets: ['module:@react-native/babel-preset'], plugins: [ + // `typechain` emits `export * as factories from './factories'` in + // src/plugins/contracts, which the React Native preset does not + // transform on its own. + '@babel/plugin-transform-export-namespace-from', isAndroid ? './node_modules/r3-hack/node_modules/react-native-reanimated/plugin' : 'react-native-worklets/plugin' From a22727ec522e1c6b5f271f88a71d4c5def2c40aa Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 02/24] Declare the hash.js dependency src/util/hmacAuth.ts imports hashjs directly, but the package was only ever resolved transitively. Declare it so a clean install and the Node CLI bundle both get it. --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index acf16d4e315..bcd70a1f96d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "ethers": "^5.7.2", "expo": "^53.0.0", "expo-quick-actions": "^5.0.0", + "hash.js": "^1.1.7", "jsrsasign": "^11.1.0", "marked": "^15.0.9", "p-debounce": "^4.0.0", diff --git a/package.json b/package.json index f1c21af3bdd..2990de46f27 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "ethers": "^5.7.2", "expo": "^53.0.0", "expo-quick-actions": "^5.0.0", + "hash.js": "^1.1.7", "jsrsasign": "^11.1.0", "marked": "^15.0.9", "p-debounce": "^4.0.0", From 259544171b5714c7d4f44d1129ccad1ab8000bd0 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 03/24] Make network and utils Node-safe `network.ts`, `utils.ts` and the locale boot each pulled React Native in through their module load paths, so nothing outside the app could fetch from the info server, format an amount, or pick a language table. Fiat constants and helpers move to `fiatConstants.ts`, and `getOsVersion` to `rnUtils.ts` alongside the other React Native-only helpers, with `keysStore.ts` following it there. `network.ts` takes its server lists and device fields through `configureNetwork` and `initInfoServer(params)` rather than reading `appConfig` and `react-native-device-info` at module scope. Locale boot splits the same way: `bootLocale.ts` applies a language table and number format with no React Native imports, and `initLocale.ts` stays GUI-only, feeding it what `react-native-localize` reports. Capturing those device fields is separate from starting the poll: `configureInfoServer` records them synchronously, so `fetchPublicRollup` works for whoever calls it first, while `initInfoServer` owns the polling and the decision to skip the unsigned launch fetch. `fetchWaterfall` refuses an empty server list rather than handing it to `asyncWaterfall`, which awaits `Promise.race([])` and never settles. --- CHANGELOG.md | 8 + eslint.config.mjs | 5 +- index.ts | 4 + src/__tests__/util/network.test.ts | 47 +++++ src/actions/LogActions.tsx | 2 +- src/app.ts | 86 ++++++++- src/components/cards/InfoCardCarousel.tsx | 2 +- src/components/scenes/GuiPluginListScene.tsx | 3 +- src/components/scenes/WalletDetailsScene.tsx | 4 +- src/components/services/EdgeCoreManager.tsx | 2 +- src/constants/WalletAndCurrencyConstants.ts | 192 ++----------------- src/hooks/useRampPreferredProviders.ts | 2 +- src/locales/bootLocale.ts | 73 +++++++ src/locales/initLocale.ts | 16 ++ src/locales/intl.ts | 35 ++-- src/locales/strings.ts | 7 - src/util/WebUtils.ts | 21 +- src/util/attestation.ts | 7 +- src/util/fiatConstants.ts | 184 ++++++++++++++++++ src/util/infoUtils.ts | 2 +- src/util/keysStore.ts | 2 +- src/util/network.ts | 146 +++++++++----- src/util/promoCardUtils.ts | 2 +- src/util/rnUtils.ts | 12 ++ src/util/txDisplay/currencyCodes.ts | 47 +++++ src/util/utils.ts | 130 ++++++------- 26 files changed, 691 insertions(+), 350 deletions(-) create mode 100644 src/__tests__/util/network.test.ts create mode 100644 src/locales/bootLocale.ts create mode 100644 src/locales/initLocale.ts create mode 100644 src/util/fiatConstants.ts create mode 100644 src/util/txDisplay/currencyCodes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 597e7c137fb..68c1fba0f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased (develop) +- changed: Moved transaction display metadata, denominations, spam-threshold resolution, local settings, the transaction export pipeline, transaction tagging, locale selection, exchange rates and the network helpers out of GUI-only modules so they load under plain Node, with no behaviour change to the app. +- fixed: Historical rates no longer re-query a pair the server has answered but cannot price, which looped without delay and never settled the caller's promise. +- fixed: Historical rate requests are capped below the rates server's 100-asset limit, where the previous check let a batch reach 101 and the rejection priced the whole page at zero. +- fixed: An unusable OS number format no longer also falls the language back to English when resolving info-server localized strings. +- fixed: `splitCategory` keeps an unrecognised category prefix as part of the subcategory rather than discarding it, so opening and saving such a transaction no longer writes the prefix away. +- fixed: Transaction fiat amounts are fetched in one batch rather than in groups of ten, each of which paid a fresh one-second debounce. +- fixed: QBO exports escape a non-ASCII payee or memo, so the `ENCODING:USASCII` header the file declares is true. +- added: Edge CLI (`edge-cli`) and its engine daemon: a long-lived process owning an `EdgeContext`, a JSON REST API over a Unix socket with an optional loopback TCP listener, and a thin client that spawns the engine on demand. 117 routes, generated command table, help text and OpenAPI reference. - added: `YOLO_OTP_KEY` env setting, which lets auto-login reach a 2FA-protected account on a device that has no login stash for it yet. - added: Slow-sync explainer card on Bitcoin-family wallets with a long transaction history. - added: Logbox disable option to env.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 17295731ec9..7556c2a4425 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -491,8 +491,6 @@ export default [ 'src/util/CryptoAmount.ts', 'src/util/cryptoTextUtils.ts', - 'src/util/exchangeRates.ts', - 'src/util/FioAddressUtils.ts', 'src/util/getAccountUsername.ts', 'src/util/GuiPluginTools.ts', @@ -508,7 +506,7 @@ export default [ 'src/util/ukComplianceUtils.ts', 'src/util/utils.ts', - 'src/util/WebUtils.ts', + 'src/util/withWatchableProps.ts' ], languageOptions: { @@ -532,6 +530,7 @@ export default [ 'android/*', 'artifacts/*', 'ios/*', + 'lib/*', 'src/plugins/contracts/*', 'src/controllers/edgeProvider/client/rolledUp.js', 'src/controllers/edgeProvider/injectThisInWebView.js' diff --git a/index.ts b/index.ts index 3a27fe7aef1..1e8c5262e6d 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,10 @@ // no-ops and the home screen shortcuts never appear. import 'expo-modules-core' import 'react-native-gesture-handler' +// Locale selection must precede ./src/app, whose import graph evaluates +// locales/strings: anything capturing lstrings at module scope would otherwise +// be frozen in English. +import './src/locales/initLocale' import './src/app' import './src/perf' diff --git a/src/__tests__/util/network.test.ts b/src/__tests__/util/network.test.ts new file mode 100644 index 00000000000..1d3a3f90985 --- /dev/null +++ b/src/__tests__/util/network.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { EdgeFetchFunction } from 'edge-core-js' + +import { configureNetwork, fetchInfo, fetchWaterfall } from '../../util/network' + +describe('fetchWaterfall', () => { + it('refuses an empty server list instead of hanging', async () => { + // `asyncWaterfall([])` awaits `Promise.race([])`, which never settles, so + // an unconfigured list used to hang the caller forever. + await expect(fetchWaterfall([], 'v1/infoRollup/edge')).rejects.toThrow( + /No servers configured for v1\/infoRollup\/edge/ + ) + }) + + it('fetches from a configured server', async () => { + const doFetch = jest.fn(async () => ({ + ok: true + })) as unknown as EdgeFetchFunction + const result = await fetchWaterfall( + ['https://info1.example'], + 'v1/thing', + undefined, + 5000, + doFetch + ) + + expect(result).toEqual({ ok: true }) + expect(doFetch).toHaveBeenCalledWith( + 'https://info1.example/v1/thing', + undefined + ) + }) +}) + +describe('configureNetwork', () => { + it('keeps the production info servers when given an empty list', async () => { + configureNetwork({ infoServers: [] }) + const doFetch = jest.fn(async () => ({ + ok: true + })) as unknown as EdgeFetchFunction + + await fetchInfo('v1/infoRollup/edge', undefined, 5000, doFetch) + + const [uri] = (doFetch as unknown as jest.Mock).mock.calls[0] as [string] + expect(uri).toMatch(/^https:\/\/info[12]\.edge\.app\//) + }) +}) diff --git a/src/actions/LogActions.tsx b/src/actions/LogActions.tsx index 00a5a4d6b2c..0cbc012aa3a 100644 --- a/src/actions/LogActions.tsx +++ b/src/actions/LogActions.tsx @@ -31,7 +31,7 @@ import type { ThunkAction } from '../types/reduxTypes' import { getCurrencyCode } from '../util/CurrencyInfoHelpers' import { base58 } from '../util/encoding' import { clearLogs, logWithType, readLogs } from '../util/logger' -import { getOsVersion } from '../util/utils' +import { getOsVersion } from '../util/rnUtils' import { getExchangeRateCacheDump } from './ExchangeRateActions' const logsUri = 'https://logs1.edge.app/v1/log/' diff --git a/src/app.ts b/src/app.ts index 83644f5c0e2..97ebd67d44d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,11 +5,22 @@ * rerenders */ // import './wdyr' +// First, and before anything that can read `lstrings`. ES module imports +// evaluate in source order, so this makes the ordering `index.ts` describes +// self-enforcing from *this* entry too: the extraction left it resting on a +// comment plus one import line in one file, which nothing checks. +import './locales/initLocale' +// Then the app-boot wiring, beside its two siblings below. This one rode on +// a binding-free side-effect import in `src/components/App.tsx`, where +// Metro's default `inlineRequires` can defer the whole module — so the one +// wiring that decides how rate-query failures are reported was the one whose +// evaluation point depended on a bundler transform. + import NetInfo from '@react-native-community/netinfo' import * as Sentry from '@sentry/react-native' import { Buffer } from 'buffer' import { asObject, asString } from 'cleaners' -import { Appearance, InteractionManager, LogBox } from 'react-native' +import { Appearance, InteractionManager, LogBox, Platform } from 'react-native' import { getVersion } from 'react-native-device-info' import RNFS from 'react-native-fs' @@ -23,8 +34,56 @@ import { CONFIG } from './config' import { KEYS } from './keys' import { config } from './theme/appConfig' import type { NumberMap } from './types/types' +import { initAttestation } from './util/attestation' +import { willSignInfoRollup } from './util/edgeApiSigner' import { log, logToServer } from './util/logger' -import { initCoinrankList, initInfoServer } from './util/network' +import { INFO_TEST_SERVER, shouldUseTestServers } from './util/maestro' +import { + configureInfoServer, + configureNetwork, + initCoinrankList, + initInfoServer +} from './util/network' +import { getOsVersion } from './util/rnUtils' +import { runOnce } from './util/runOnce' +import { checkAppVersion } from './util/versionCheck' + +// `CONFIG.INFO_SERVER` overrides the production info servers, +// e.g. to point a debug build at a local info server. Absent in production +// builds. +configureNetwork({ + infoServers: + CONFIG.INFO_SERVER != null && CONFIG.INFO_SERVER.length > 0 + ? CONFIG.INFO_SERVER + : shouldUseTestServers() + ? [INFO_TEST_SERVER] + : undefined, + referralServers: config.referralServers ?? [], + notificationServers: config.notificationServers +}) + +// `keysStore` falls back to `fetchPublicRollup` on the cold-start path, which +// runs from the first render — before the NetInfo listener below reaches +// `initInfoServer`. Capturing the parameters here, synchronously, keeps that +// fallback working whichever lands first. +/** + * The device and app fields the info server is told about. + * + * One object, because `configureInfoServer` and `initInfoServer` take the + * same five and they were written out twice with an identical `onRollup` + * closure — the kind of pair that drifts in one place and not the other. + */ +const infoServerParams = { + osType: Platform.OS.toLowerCase(), + osVersion: getOsVersion(), + appVersion: getVersion(), + appId: config.appId ?? 'edge', + onRollup: async () => { + await runOnce('checkAppVersion', checkAppVersion) + } +} + +configureInfoServer(infoServerParams) export type Environment = 'development' | 'testing' | 'production' @@ -340,9 +399,26 @@ NetInfo.addEventListener(state => { const currentConnectionState = state.isConnected ?? false if (!previousConnectionState && currentConnectionState) { console.log('Network connected, refreshing info and coinrank...') - initInfoServer().catch((err: unknown) => { - console.log(err) - }) + // Start attestation at reconnect (idempotent); previously lived in + // initInfoServer before network.ts was made Node-safe. + initAttestation() + // `willSignInfoRollup` reads the native signer, so it is awaited here + // rather than inside `network.ts`, which has to stay Node-safe. Its + // failure must not skip the refresh: a rejection used to be swallowed by + // the outer catch, so the reconnect rollup fetch never ran at all, where + // before this branch it always did. `false` means "do not skip the + // unsigned fetch", which is the safe reading of "we could not tell". + willSignInfoRollup() + .catch((error: unknown) => { + console.log(error) + return false + }) + .then(async skipUnsignedLaunchFetch => { + await initInfoServer({ ...infoServerParams, skipUnsignedLaunchFetch }) + }) + .catch((error: unknown) => { + console.log(error) + }) initCoinrankList().catch((err: unknown) => { console.log(err) }) diff --git a/src/components/cards/InfoCardCarousel.tsx b/src/components/cards/InfoCardCarousel.tsx index 10fea2da109..f1cafbe4435 100644 --- a/src/components/cards/InfoCardCarousel.tsx +++ b/src/components/cards/InfoCardCarousel.tsx @@ -12,7 +12,7 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import { type DisplayInfoCard, getDisplayInfoCards } from '../../util/infoUtils' import { addPromoCardToNotifications } from '../../util/promoCardUtils' -import { getOsVersion } from '../../util/utils' +import { getOsVersion } from '../../util/rnUtils' import { type Anim, EdgeAnim } from '../common/EdgeAnim' import { type CarouselRenderItem, EdgeCarousel } from '../common/EdgeCarousel' import { useTheme } from '../services/ThemeContext' diff --git a/src/components/scenes/GuiPluginListScene.tsx b/src/components/scenes/GuiPluginListScene.tsx index bde7b79d636..de6592ef365 100644 --- a/src/components/scenes/GuiPluginListScene.tsx +++ b/src/components/scenes/GuiPluginListScene.tsx @@ -56,8 +56,9 @@ import { filterGuiPluginJson } from '../../util/GuiPluginTools' import { getDisplayInfoCards } from '../../util/infoUtils' import { infoServerData } from '../../util/network' import { bestOfPlugins } from '../../util/ReferralHelpers' +import { getOsVersion } from '../../util/rnUtils' import { logEvent, type OnLogEvent } from '../../util/tracking' -import { base58ToUuid, getOsVersion } from '../../util/utils' +import { base58ToUuid } from '../../util/utils' import { EdgeCard } from '../cards/EdgeCard' import { PaymentOptionCard } from '../cards/PaymentOptionCard' import { diff --git a/src/components/scenes/WalletDetailsScene.tsx b/src/components/scenes/WalletDetailsScene.tsx index a15947e4691..93f91e67cf3 100644 --- a/src/components/scenes/WalletDetailsScene.tsx +++ b/src/components/scenes/WalletDetailsScene.tsx @@ -41,11 +41,11 @@ import type { } from '../../types/routerTypes' import { getDisplayInfoCards } from '../../util/infoUtils' import { coinrankListData, infoServerData } from '../../util/network' +import { getOsVersion } from '../../util/rnUtils' import { calculateSpamThreshold, convertNativeToDenomination, - darkenHexColor, - getOsVersion + darkenHexColor } from '../../util/utils' import { EdgeCard } from '../cards/EdgeCard' import { InfoCardCarousel } from '../cards/InfoCardCarousel' diff --git a/src/components/services/EdgeCoreManager.tsx b/src/components/services/EdgeCoreManager.tsx index c16adbd1d8a..61594b1af56 100644 --- a/src/components/services/EdgeCoreManager.tsx +++ b/src/components/services/EdgeCoreManager.tsx @@ -52,7 +52,7 @@ import { shouldUseTestServers, SYNC_TEST_SERVER } from '../../util/maestro' -import { getOsVersion } from '../../util/utils' +import { getOsVersion } from '../../util/rnUtils' import { LoadingSplashScreen } from '../progress-indicators/LoadingSplashScreen' import { showError } from './AirshipInstance' import { Providers } from './Providers' diff --git a/src/constants/WalletAndCurrencyConstants.ts b/src/constants/WalletAndCurrencyConstants.ts index d7934652e44..9df6dddc1f2 100644 --- a/src/constants/WalletAndCurrencyConstants.ts +++ b/src/constants/WalletAndCurrencyConstants.ts @@ -6,17 +6,28 @@ import { Platform } from 'react-native' import { lstrings } from '../locales/strings' import type { WalletConnectChainId } from '../types/types' import { utxoPlugins } from '../util/corePlugins' +import { + FEE_ALERT_THRESHOLD, + FEE_COLOR_THRESHOLD, + FIAT_CODES_SYMBOLS, + FIAT_PRECISION, + getFiatSymbol +} from '../util/fiatConstants' import { asMoneroUserSettings, isMoneroEdgeLws } from '../util/monero' -import { removeIsoPrefix } from '../util/utils' -export const MAX_TOKEN_CODE_CHARACTERS = 7 +// Re-export fiat helpers so existing importers stay unchanged +export { + FEE_ALERT_THRESHOLD, + FEE_COLOR_THRESHOLD, + FIAT_CODES_SYMBOLS, + FIAT_PRECISION, + getFiatSymbol +} -export const FEE_COLOR_THRESHOLD = 2.0 // this is denominated in dollars -export const FEE_ALERT_THRESHOLD = 5.0 // this is denominated in dollars +export const MAX_TOKEN_CODE_CHARACTERS = 7 export const MAX_ADDRESS_CHARACTERS = 17 // for displaying a truncated wallet address export const MAX_CRYPTO_AMOUNT_CHARACTERS = 10 // includes both whole and fractional characters -export const FIAT_PRECISION = 2 const UTXO_MAX_SPEND_TARGETS = 32 // Sync status consts @@ -1206,177 +1217,6 @@ export function isKeysOnlyModeDate(date: Date): boolean { } export const USD_FIAT = 'iso:USD' -/** - * Get the fiat symbol from an iso:[fiat] OR fiat currency code - */ -export const getFiatSymbol = (isoOrFiatCurrencyCode: string): string => { - if (typeof isoOrFiatCurrencyCode !== 'string') return '' - const codeWithoutIso = removeIsoPrefix(isoOrFiatCurrencyCode) - const out = FIAT_CODES_SYMBOLS[codeWithoutIso.toUpperCase()] - return out ?? '' -} -export const FIAT_CODES_SYMBOLS: Record = { - AED: 'د.إ', - AFN: '؋', - ALL: 'L', - AMD: '֏', - ANG: 'ƒ', - AOA: 'Kz', - ARS: '$', - AUD: '$', - AWG: 'ƒ', - AZN: '₼', - BAM: 'KM', - BBD: '$', - BDT: '৳', - BGN: 'лв', - BIF: 'Fr', - BMD: '$', - BND: '$', - BOB: 'Bs.', - BRL: 'R$', - BSD: '$', - BTN: 'Nu.', - BWP: 'P', - BYN: 'Br', - BZD: '$', - CAD: '$', - CDF: 'Fr', - CHF: 'Fr', - CLP: '$', - CNY: '¥', - COP: '$', - CRC: '₡', - CUC: '$', - CUP: '$', - CVE: '$', - CZK: 'Kč', - DJF: 'Fr', - DKK: 'kr', - DOP: '$', - DZD: 'د.ج', - EGP: 'ج.م', - ERN: 'Nfk', - ETB: 'Br', - EUR: '€', - FJD: '$', - FKP: '£', - GBP: '£', - GEL: '₾', - GGP: '£', - GHS: '₵', - GIP: '£', - GMD: 'D', - GNF: 'Fr', - GTQ: 'Q', - GYD: '$', - HKD: '$', - HNL: 'L', - HRK: 'kn', - HTG: 'G', - HUF: 'Ft', - IDR: 'Rp', - ILS: '₪', - IMP: '£', - INR: '₹', - IQD: 'ع.د', - IRR: '﷼', - ISK: 'kr', - JEP: '£', - JMD: '$', - JOD: 'د.ا', - JPY: '¥', - KES: 'Sh', - KGS: 'с', - KHR: '៛', - KMF: 'Fr', - KPW: '₩', - KRW: '₩', - KWD: 'د.ك', - KYD: '$', - KZT: '₸', - LAK: '₭', - LBP: 'ل.ل', - LKR: 'Rs', - LRD: '$', - LSL: 'L', - LYD: 'ل.د', - MAD: 'د. م.', - MDL: 'L', - MGA: 'Ar', - MKD: 'ден', - MMK: 'Ks', - MNT: '₮', - MOP: 'P', - MRO: 'UM', - MRU: 'UM', - MUR: '₨', - MWK: 'MK', - MXN: '$', - MYR: 'RM', - MZN: 'MT', - NAD: '$', - NGN: '₦', - NIO: 'C$', - NOK: 'kr', - NPR: '₨', - NZD: '$', - OMR: 'ر.ع.', - PAB: 'B/.', - PEN: 'S/.', - PGK: 'K', - PHP: '₱', - PKR: '₨', - PLN: 'zł', - PRB: 'р.', - PYG: '₲', - QAR: 'ر.ق', - RON: 'lei', - RSD: 'дин', - RUB: '₽', - RWF: 'Fr', - SAR: 'ر.س', - SBD: '$', - SCR: '₨', - SDG: 'ج.س.', - SEK: 'kr', - SGD: '$', - SHP: '£', - SLL: 'Le', - SOS: 'Sh', - SRD: '$', - SSP: '£', - STD: 'Db', - SYP: 'ل.س', - SZL: 'L', - THB: '฿', - TJS: 'ЅМ', - TMT: 'm', - TND: 'د.ت', - TOP: 'T$', - TRY: '₺', - TTD: '$', - TVD: '$', - TWD: '$', - TZS: 'Sh', - UAH: '₴', - UGX: 'Sh', - USD: '$', - UYU: '$', - UZS: '', - VEF: 'Bs', - VND: '₫', - VUV: 'Vt', - WST: 'T', - XAF: 'Fr', - XCD: '$', - XOF: 'Fr', - XPF: 'Fr', - YER: '﷼', - ZAR: 'R', - ZMW: 'ZK' -} - export const FIO_WALLET_TYPE = 'wallet:fio' export const FIO_STR = 'FIO' export const FIO_PLUGIN_ID = 'fio' diff --git a/src/hooks/useRampPreferredProviders.ts b/src/hooks/useRampPreferredProviders.ts index 33459927e12..aaafe80fb21 100644 --- a/src/hooks/useRampPreferredProviders.ts +++ b/src/hooks/useRampPreferredProviders.ts @@ -5,7 +5,7 @@ import { getBuildNumber, getVersion } from 'react-native-device-info' import { useSelector } from '../types/reactRedux' import { filterInfoCards } from '../util/infoUtils' import { infoServerData } from '../util/network' -import { getOsVersion } from '../util/utils' +import { getOsVersion } from '../util/rnUtils' /** * Ramp provider ids that the account's affiliation prefers for this direction, diff --git a/src/locales/bootLocale.ts b/src/locales/bootLocale.ts new file mode 100644 index 00000000000..79d3e03dae7 --- /dev/null +++ b/src/locales/bootLocale.ts @@ -0,0 +1,73 @@ +/** + * Node-safe locale boot. Mutates `lstrings` and `intl.locale`. + * GUI and CLI inject detection; this file must not import react-native. + */ +import { setIntlLocale } from './intl' +import { selectLocale } from './strings' + +export interface LocaleSource { + languageTag: string + decimalSeparator: string + groupingSeparator: string +} + +export interface AppliedLocale extends LocaleSource { + matched: boolean +} + +const DEFAULT_LANGUAGE_TAG = 'en-US' + +let hasApplied = false +let applied: AppliedLocale = { + languageTag: DEFAULT_LANGUAGE_TAG, + decimalSeparator: '.', + groupingSeparator: ',', + matched: true +} + +function isDefaultEnglish(tag: string): boolean { + const compact = tag.replace(/[-_]/g, '').toLowerCase() + return compact === 'enus' || compact === 'en' +} + +/** + * Apply language tables and number format. Call once at process start. + * + * Idempotent, because both `index.ts` and `src/app.ts` now import the GUI's + * boot module: the module cache makes that one evaluation, and a caller that + * does reach here twice with the same source gets the same answer rather + * than re-merging the tables. + */ +export function applyLocale(source: LocaleSource): AppliedLocale { + const languageTag = + source.languageTag === '' ? DEFAULT_LANGUAGE_TAG : source.languageTag + if ( + hasApplied && + applied.languageTag === languageTag && + applied.decimalSeparator === source.decimalSeparator && + applied.groupingSeparator === source.groupingSeparator + ) { + return applied + } + hasApplied = true + let matched = true + if (!isDefaultEnglish(languageTag)) { + matched = selectLocale(languageTag) + } + setIntlLocale({ + localeIdentifier: languageTag, + decimalSeparator: source.decimalSeparator, + groupingSeparator: source.groupingSeparator + }) + applied = { + languageTag, + decimalSeparator: source.decimalSeparator, + groupingSeparator: source.groupingSeparator, + matched + } + return applied +} + +export function getAppliedLocale(): AppliedLocale { + return applied +} diff --git a/src/locales/initLocale.ts b/src/locales/initLocale.ts new file mode 100644 index 00000000000..9c5d8c66392 --- /dev/null +++ b/src/locales/initLocale.ts @@ -0,0 +1,16 @@ +/** + * GUI-only locale boot. Call once at app startup so locales/strings and + * locales/intl stay free of react-native-localize module-load side effects. + */ +import { getLocales, getNumberFormatSettings } from 'react-native-localize' + +import { applyLocale } from './bootLocale' + +const [firstLocale = { languageTag: 'en-US' }] = getLocales() +const { languageTag = 'en-US' } = firstLocale +const numberFormat = getNumberFormatSettings() +applyLocale({ + languageTag, + decimalSeparator: numberFormat.decimalSeparator, + groupingSeparator: numberFormat.groupingSeparator +}) diff --git a/src/locales/intl.ts b/src/locales/intl.ts index 4056a1f3e19..5e37c8b5cda 100644 --- a/src/locales/intl.ts +++ b/src/locales/intl.ts @@ -1,7 +1,6 @@ import { gt, mul, toBns, toFixed } from 'biggystring' import { asMaybe } from 'cleaners' import { format } from 'date-fns' -import { getLocales, getNumberFormatSettings } from 'react-native-localize' import { sprintf } from 'sprintf-js' import { asBiggystring } from '../util/cleaners' @@ -36,11 +35,6 @@ const NATIVE_DECIMAL_SEPARATOR = '.' const NUMBER_GROUP_SIZE = 3 export const locale: IntlLocaleType = { ...EN_US_LOCALE } -// Set the locale at boot: -const [firstLocale = { languageTag: 'en_US' }] = getLocales() -const numberFormat = getNumberFormatSettings() -setIntlLocale({ localeIdentifier: firstLocale.languageTag, ...numberFormat }) - /** * Formats number input according to user locale * Allows decimalSeparator at the end of string @@ -282,18 +276,30 @@ export function setIntlLocale(l: IntlLocaleType): void { throw new Error('Please select locale for internationalization') } - if ( - l.decimalSeparator === '' || - l.groupingSeparator === '' || - l.localeIdentifier === '' - ) { + // The number format and the language are separate failures. An OS that + // reports an empty separator says nothing about the language, and + // discarding the whole locale meant `getLocaleOrDefaultString` — which now + // reads `locale.localeIdentifier` — resolved info-server localized strings + // in English on such a device, where only number formatting used to fall + // back. + if (l.localeIdentifier === '') { console.warn( 'Cannot recognize user locale preferences. Default will be used.' ) Object.assign(locale, EN_US_LOCALE) - } else { - Object.assign(locale, l) + return + } + if (l.decimalSeparator === '' || l.groupingSeparator === '') { + console.warn( + 'Cannot recognize user number format preferences. Default will be used.' + ) + Object.assign(locale, l, { + decimalSeparator: EN_US_LOCALE.decimalSeparator, + groupingSeparator: EN_US_LOCALE.groupingSeparator + }) + return } + Object.assign(locale, l) } export function toLocaleDate(date: Date): string { @@ -402,8 +408,7 @@ export const pickLanguage = ( export const getLocaleOrDefaultString = ( localizedStrings: Record ): string | undefined => { - const [firstLocale = { languageTag: DEFAULT_LOCALE_ID }] = getLocales() - const { languageTag } = firstLocale + const languageTag = locale.localeIdentifier const localizedStringKeys = Object.keys(localizedStrings) let localeId = pickLanguage(languageTag, localizedStringKeys) diff --git a/src/locales/strings.ts b/src/locales/strings.ts index a743f741101..cdbe4cd1094 100644 --- a/src/locales/strings.ts +++ b/src/locales/strings.ts @@ -1,5 +1,3 @@ -import { getLocales } from 'react-native-localize' - import en from './en_US' import de from './strings/de.json' import es from './strings/es.json' @@ -20,11 +18,6 @@ export type LStrings = typeof lstrings export type LStringsKey = keyof LStrings export type LStringsValues = LStrings[LStringsKey] -// Set the language at boot: -const [firstLocale] = getLocales() -const { languageTag = 'en-US' } = firstLocale ?? {} -if (languageTag !== 'en-US') selectLocale(languageTag) - function mergeStrings( primary: Record, secondary: Record diff --git a/src/util/WebUtils.ts b/src/util/WebUtils.ts index f7c7249a8e5..7ed47da8bb9 100644 --- a/src/util/WebUtils.ts +++ b/src/util/WebUtils.ts @@ -34,9 +34,24 @@ export const stringifyQuery = (query: UriQueryMap): string => { export const parseQuery = (query?: string): UriQueryMap => { if (query == null) return {} const dummyUrl = new URL('https://dummyurl.com?' + query, true) - const test = dummyUrl.query - // @ts-expect-error - return test + // `url-parse` types `query` as `string | Record` depending + // on its `parseQuery` flag, which it cannot narrow from the `true` above. + // Narrowed rather than suppressed, which is the point of the change here. + // + // The `?? null` below is defensive, not a fix for an observed bug: the + // library's querystring parser yields `''` for a value-less flag, never + // `undefined` — `new URL('https://x?flag', true).query` is + // `{ flag: '' }`, which is why `cleanQueryFlags` above exists at all — but + // its types say `string | undefined`, and `UriQueryMap` promises + // `string | null`, so this keeps the two honest without claiming the + // `null` arm is reachable today. + const parsed = dummyUrl.query + if (typeof parsed === 'string') return {} + const out: UriQueryMap = {} + for (const [key, value] of Object.entries(parsed)) { + out[key] = value ?? null + } + return out } /** diff --git a/src/util/attestation.ts b/src/util/attestation.ts index d2cfdeb863a..3b4e5efe828 100644 --- a/src/util/attestation.ts +++ b/src/util/attestation.ts @@ -740,9 +740,10 @@ const runHandshake = (): void => { * (unless a live token is already cached) without blocking; the engine then * self-reschedules to refresh the token ahead of each expiry. * - * Called from `initInfoServer`, which runs on every network reconnect and not - * just at boot, so this has to be idempotent: it returns immediately while a - * token is live, and `runHandshake` single-flights and rate-limits the rest. + * Called from the network-reconnect path in `app.ts` (alongside + * `initInfoServer`), so this has to be idempotent: it returns immediately + * while a token is live, and `runHandshake` single-flights and rate-limits + * the rest. */ export const initAttestation = (): void => { if (canServeToken()) return diff --git a/src/util/fiatConstants.ts b/src/util/fiatConstants.ts new file mode 100644 index 00000000000..f3bf59a2db6 --- /dev/null +++ b/src/util/fiatConstants.ts @@ -0,0 +1,184 @@ +/** + * Node-safe fiat display constants. Split out of WalletAndCurrencyConstants + * so util/utils.ts can import them without pulling in react-native Platform. + */ + +export const FEE_COLOR_THRESHOLD = 2.0 // this is denominated in dollars +export const FEE_ALERT_THRESHOLD = 5.0 // this is denominated in dollars +export const FIAT_PRECISION = 2 + +export const removeIsoPrefix = (currencyCode: string): string => { + return currencyCode.replace('iso:', '') +} + +export const FIAT_CODES_SYMBOLS: Record = { + AED: 'د.إ', + AFN: '؋', + ALL: 'L', + AMD: '֏', + ANG: 'ƒ', + AOA: 'Kz', + ARS: '$', + AUD: '$', + AWG: 'ƒ', + AZN: '₼', + BAM: 'KM', + BBD: '$', + BDT: '৳', + BGN: 'лв', + BIF: 'Fr', + BMD: '$', + BND: '$', + BOB: 'Bs.', + BRL: 'R$', + BSD: '$', + BTN: 'Nu.', + BWP: 'P', + BYN: 'Br', + BZD: '$', + CAD: '$', + CDF: 'Fr', + CHF: 'Fr', + CLP: '$', + CNY: '¥', + COP: '$', + CRC: '₡', + CUC: '$', + CUP: '$', + CVE: '$', + CZK: 'Kč', + DJF: 'Fr', + DKK: 'kr', + DOP: '$', + DZD: 'د.ج', + EGP: 'ج.م', + ERN: 'Nfk', + ETB: 'Br', + EUR: '€', + FJD: '$', + FKP: '£', + GBP: '£', + GEL: '₾', + GGP: '£', + GHS: '₵', + GIP: '£', + GMD: 'D', + GNF: 'Fr', + GTQ: 'Q', + GYD: '$', + HKD: '$', + HNL: 'L', + HRK: 'kn', + HTG: 'G', + HUF: 'Ft', + IDR: 'Rp', + ILS: '₪', + IMP: '£', + INR: '₹', + IQD: 'ع.د', + IRR: '﷼', + ISK: 'kr', + JEP: '£', + JMD: '$', + JOD: 'د.ا', + JPY: '¥', + KES: 'Sh', + KGS: 'с', + KHR: '៛', + KMF: 'Fr', + KPW: '₩', + KRW: '₩', + KWD: 'د.ك', + KYD: '$', + KZT: '₸', + LAK: '₭', + LBP: 'ل.ل', + LKR: 'Rs', + LRD: '$', + LSL: 'L', + LYD: 'ل.د', + MAD: 'د. م.', + MDL: 'L', + MGA: 'Ar', + MKD: 'ден', + MMK: 'Ks', + MNT: '₮', + MOP: 'P', + MRO: 'UM', + MRU: 'UM', + MUR: '₨', + MWK: 'MK', + MXN: '$', + MYR: 'RM', + MZN: 'MT', + NAD: '$', + NGN: '₦', + NIO: 'C$', + NOK: 'kr', + NPR: '₨', + NZD: '$', + OMR: 'ر.ع.', + PAB: 'B/.', + PEN: 'S/.', + PGK: 'K', + PHP: '₱', + PKR: '₨', + PLN: 'zł', + PRB: 'р.', + PYG: '₲', + QAR: 'ر.ق', + RON: 'lei', + RSD: 'дин', + RUB: '₽', + RWF: 'Fr', + SAR: 'ر.س', + SBD: '$', + SCR: '₨', + SDG: 'ج.س.', + SEK: 'kr', + SGD: '$', + SHP: '£', + SLL: 'Le', + SOS: 'Sh', + SRD: '$', + SSP: '£', + STD: 'Db', + SYP: 'ل.س', + SZL: 'L', + THB: '฿', + TJS: 'ЅМ', + TMT: 'm', + TND: 'د.ت', + TOP: 'T$', + TRY: '₺', + TTD: '$', + TVD: '$', + TWD: '$', + TZS: 'Sh', + UAH: '₴', + UGX: 'Sh', + USD: '$', + UYU: '$', + UZS: '', + VEF: 'Bs', + VND: '₫', + VUV: 'Vt', + WST: 'T', + XAF: 'Fr', + XCD: '$', + XOF: 'Fr', + XPF: 'Fr', + YER: '﷼', + ZAR: 'R', + ZMW: 'ZK' +} + +/** + * Get the fiat symbol from an iso:[fiat] OR fiat currency code + */ +export const getFiatSymbol = (isoOrFiatCurrencyCode: string): string => { + if (typeof isoOrFiatCurrencyCode !== 'string') return '' + const codeWithoutIso = removeIsoPrefix(isoOrFiatCurrencyCode) + const out = FIAT_CODES_SYMBOLS[codeWithoutIso.toUpperCase()] + return out ?? '' +} diff --git a/src/util/infoUtils.ts b/src/util/infoUtils.ts index 42b40bcd445..6a1cd41693e 100644 --- a/src/util/infoUtils.ts +++ b/src/util/infoUtils.ts @@ -5,7 +5,7 @@ import { getBuildNumber, getVersion } from 'react-native-device-info' import { infoServerData } from './network' import { getPromoCardMessageId } from './promoCardUtils' -import { getOsVersion } from './utils' +import { getOsVersion } from './rnUtils' export interface DisplayInfoCard { background: InfoCard['background'] diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts index 32a9c95eb6c..a832b961969 100644 --- a/src/util/keysStore.ts +++ b/src/util/keysStore.ts @@ -26,8 +26,8 @@ import { type FetchCredentials, fetchRemoteKeys } from './keysServer' import { debugLog } from './logger' import { fetchPublicRollup, infoServerData } from './network' import { raceTimeout, TIMED_OUT } from './raceTimeout' +import { getOsVersion } from './rnUtils' import { runOnce } from './runOnce' -import { getOsVersion } from './utils' import { checkAppVersion } from './versionCheck' export type KeysTier = 'remote' | 'cache' | 'baked-in' diff --git a/src/util/network.ts b/src/util/network.ts index e647af95dec..44805a988da 100644 --- a/src/util/network.ts +++ b/src/util/network.ts @@ -5,31 +5,42 @@ import type { EdgeFetchResponse } from 'edge-core-js' import { asInfoRollup, type InfoRollup } from 'edge-info-server' -import { Platform } from 'react-native' -import { getVersion } from 'react-native-device-info' - -import { CONFIG } from '../config' -import { config } from '../theme/appConfig' -import { initAttestation } from './attestation' -import { willSignInfoRollup } from './edgeApiSigner' -import { INFO_TEST_SERVER, shouldUseTestServers } from './maestro' -import { runOnce } from './runOnce' -import { asyncWaterfall, getOsVersion, shuffleArray } from './utils' -import { checkAppVersion } from './versionCheck' -// `CONFIG.INFO_SERVER` (from config.json) overrides the production info servers, -// e.g. to point a debug build at a local info server. Absent in production -// builds. -const INFO_SERVERS = - CONFIG.INFO_SERVER != null && CONFIG.INFO_SERVER.length > 0 - ? CONFIG.INFO_SERVER - : shouldUseTestServers() - ? [INFO_TEST_SERVER] - : ['https://info1.edge.app', 'https://info2.edge.app'] + +import { asyncWaterfall, shuffleArray } from './utils' + +export const DEFAULT_INFO_SERVERS = [ + 'https://info1.edge.app', + 'https://info2.edge.app' +] const RATES_SERVERS = ['https://rates3.edge.app', 'https://rates4.edge.app'] const RATES_SERVER_V2 = ['https://rates1.edge.app', 'https://rates2.edge.app'] const INFO_FETCH_INTERVAL = 5 * 60 * 1000 // 5 minutes +let infoServers: string[] = DEFAULT_INFO_SERVERS +let referralServers: string[] = [] +let notificationServers: string[] = [] +let infoServerPollStarted = false + +/** + * GUI wires referral/push/info server lists from appConfig/ENV at startup. + * Until configured, referral/push fetches use an empty list; info defaults + * to production hosts. + */ +export function configureNetwork(opts: { + infoServers?: string[] + referralServers?: string[] + notificationServers?: string[] +}): void { + if (opts.infoServers != null && opts.infoServers.length > 0) { + infoServers = opts.infoServers + } + if (opts.referralServers != null) referralServers = opts.referralServers + if (opts.notificationServers != null) { + notificationServers = opts.notificationServers + } +} + export async function fetchWaterfall( servers: string[], path: string, @@ -37,6 +48,13 @@ export async function fetchWaterfall( timeout: number = 5000, doFetch: EdgeFetchFunction = fetch ): Promise { + // `asyncWaterfall([])` awaits `Promise.race([])`, which never settles. The + // referral and push lists start empty until `configureNetwork` fills them, + // and the CLI configures only the info servers, so an unconfigured caller + // would hang forever rather than fail. + if (servers.length === 0) { + throw new Error(`No servers configured for ${path}`) + } const funcs = servers.map(server => async () => { const result = await doFetch(server + '/' + path, options) if (typeof result !== 'object') { @@ -96,7 +114,7 @@ export const fetchInfo = async ( timeout?: number, doFetch?: EdgeFetchFunction ): Promise => { - return await multiFetch(INFO_SERVERS, path, options, timeout, doFetch) + return await multiFetch(infoServers, path, options, timeout, doFetch) } export const fetchRates = async ( path: string, @@ -113,13 +131,7 @@ export const fetchReferral = async ( timeout?: number, doFetch?: EdgeFetchFunction ): Promise => { - return await multiFetch( - config.referralServers ?? [], - path, - options, - timeout, - doFetch - ) + return await multiFetch(referralServers, path, options, timeout, doFetch) } export const fetchPush = async ( path: string, @@ -127,13 +139,7 @@ export const fetchPush = async ( timeout?: number, doFetch?: EdgeFetchFunction ): Promise => { - return await multiFetch( - config.notificationServers, - path, - options, - timeout, - doFetch - ) + return await multiFetch(notificationServers, path, options, timeout, doFetch) } export const infoServerData: { @@ -141,45 +147,79 @@ export const infoServerData: { rollupRaw?: unknown } = {} -let infoServerPollStarted = false +export interface InitInfoServerParams { + osType: string + osVersion: string + appVersion: string + appId: string + /** Called once after a successful rollup fetch (e.g. version check). */ + onRollup?: () => Promise + /** + * When true, skip the launch unsigned fetch (HMAC signed fetch will fill + * rollup + appKeys). Unsigned is enough when this build has no HMAC + * credentials. + */ + skipUnsignedLaunchFetch?: boolean +} + +let infoServerParams: InitInfoServerParams | undefined /** * Fetch the unsigned public info rollup. Exported so `keysStore` can fall back * to it when the signed infoRollup fetch fails to populate `infoServerData`: * that failure is only observable once the signed fetch settles, which is long - * after `initInfoServer` has already run. + * after `initInfoServer` has already run. Uses the parameters captured by + * `initInfoServer`, so this module stays Node-safe. */ export const fetchPublicRollup = async (): Promise => { - const osType = Platform.OS.toLowerCase() - const osVersion = getOsVersion() - const version = getVersion() + const params = infoServerParams + if (params == null) { + console.warn( + 'fetchPublicRollup: configureInfoServer has not run yet, so there are no device fields to send' + ) + return + } + const { osType, osVersion, appVersion, appId, onRollup } = params try { const response = await fetchInfo( - `v1/infoRollup/${ - config.appId ?? 'edge' - }?os=${osType}&osVersion=${osVersion}&appVersion=${version}` + `v1/infoRollup/${appId}?os=${osType}&osVersion=${osVersion}&appVersion=${appVersion}` ) if (!response.ok) { console.warn( - `initInfoServer error ${response.status}: ${await response.text()}` + `fetchPublicRollup error ${response.status}: ${await response.text()}` ) } else { const infoData = await response.json() infoServerData.rollupRaw = infoData infoServerData.rollup = asInfoRollup(infoData) - await runOnce('checkAppVersion', checkAppVersion) + if (onRollup != null) await onRollup() } } catch (e) { - console.warn('initInfoServer: Failed to ping info server') + console.warn('fetchPublicRollup: Failed to reach the info server') } } -export const initInfoServer = async (): Promise => { - // Start the background attestation engine at boot (best-effort, non-blocking) - // so a token is usually cached before any attestation-gated request is made. - // This is intentionally not inside fetchInfo: the fetch wrapper carries no - // attestation logic; gated plugins attach the token via getAttestationToken(). - initAttestation() +/** + * Record the fields `fetchPublicRollup` needs. + * + * Separate from `initInfoServer` because the GUI can supply all of these + * synchronously at startup, while the decision to skip the unsigned launch + * fetch depends on an async native read. `keysStore` calls + * `fetchPublicRollup` directly on the cold-start path, so it must not depend + * on that await having finished. + */ +export function configureInfoServer(params: InitInfoServerParams): void { + infoServerParams = params +} + +export const initInfoServer = async ( + params: InitInfoServerParams +): Promise => { + // Through `configureInfoServer` rather than assigning again: the whole + // body of that function is this one line, and two places writing the same + // module state is how the two drift. + configureInfoServer(params) + const { skipUnsignedLaunchFetch } = params const queryInfo = fetchPublicRollup @@ -198,7 +238,7 @@ export const initInfoServer = async (): Promise => { // populate the rollup, `keysStore` calls `fetchPublicRollup` directly: the // decision cannot be made here, because at this point the signed fetch is // usually still in flight rather than failed. - if (infoServerData.rollup == null && !(await willSignInfoRollup())) { + if (infoServerData.rollup == null && skipUnsignedLaunchFetch !== true) { await queryInfo() } diff --git a/src/util/promoCardUtils.ts b/src/util/promoCardUtils.ts index ff612b77960..aafaeb05c69 100644 --- a/src/util/promoCardUtils.ts +++ b/src/util/promoCardUtils.ts @@ -11,7 +11,7 @@ import { } from '../actions/LocalSettingsActions' import { getLocaleOrDefaultString } from '../locales/intl' import { type DisplayInfoCard, filterInfoCards } from './infoUtils' -import { getOsVersion } from './utils' +import { getOsVersion } from './rnUtils' /** * Generate a unique notification key for a promo card diff --git a/src/util/rnUtils.ts b/src/util/rnUtils.ts index 1b9b7e47e89..af56b73f053 100644 --- a/src/util/rnUtils.ts +++ b/src/util/rnUtils.ts @@ -1,3 +1,4 @@ +import DeviceInfo from 'react-native-device-info' import { generateSecureRandom } from 'react-native-securerandom' import { v4 } from 'uuid' @@ -15,3 +16,14 @@ export const makeUuid = async (): Promise => { const uuid = v4({ random: bytes }) return uuid } + +/** + * Reads and normalizes the OS version. + */ +export function getOsVersion(): string { + const osVersionRaw = DeviceInfo.getSystemVersion() + return Array.from({ length: 3 }, (_, i) => { + const part = osVersionRaw.split('.')[i] + return part != null && part !== '' ? part : '0' + }).join('.') +} diff --git a/src/util/txDisplay/currencyCodes.ts b/src/util/txDisplay/currencyCodes.ts new file mode 100644 index 00000000000..3fa784f7999 --- /dev/null +++ b/src/util/txDisplay/currencyCodes.ts @@ -0,0 +1,47 @@ +import type { EdgeAccount, EdgeTokenId } from 'edge-core-js' + +export const getCurrencyCodeWithAccount = ( + account: EdgeAccount, + pluginId: string, + tokenId: EdgeTokenId +): string | undefined => { + if (account.currencyConfig[pluginId] == null) { + return + } + + if (tokenId == null) { + return account.currencyConfig[pluginId].currencyInfo.currencyCode + } + if (account.currencyConfig[pluginId].allTokens[tokenId] == null) { + console.warn( + `getCurrencyCodeWithAccount: tokenId: '${tokenId}' not found for pluginId: '${pluginId}'` + ) + return '' + } + return account.currencyConfig[pluginId].allTokens[tokenId].currencyCode +} + +/** + * The currency code a wallet reports for one of its own assets. + * + * The wallet-based sibling of `getCurrencyCodeWithAccount`, for a caller that + * has the wallet rather than the account. Structurally typed, so it needs + * neither `edge-core-js` nor the GUI's `SPECIAL_CURRENCY_INFO`. + */ +export const currencyCodeForToken = ( + wallet: { + currencyInfo: { currencyCode: string; pluginId: string } + currencyConfig: { allTokens: Record } + }, + tokenId: string | null +): string => { + if (tokenId == null) return wallet.currencyInfo.currencyCode + const token = wallet.currencyConfig.allTokens[tokenId] + if (token == null) { + console.warn( + `currencyCodeForToken: tokenId: '${tokenId}' not found for wallet pluginId: '${wallet.currencyInfo.pluginId}'` + ) + return '' + } + return token.currencyCode +} diff --git a/src/util/utils.ts b/src/util/utils.ts index ef2d80ac633..ec8f5c5ec19 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -9,20 +9,10 @@ import type { EdgeTokenMap, EdgeTransaction } from 'edge-core-js' -import { Linking, Platform } from 'react-native' -import DeviceInfo from 'react-native-device-info' -import SafariView from 'react-native-safari-view' import { sprintf } from 'sprintf-js' import { v4 } from 'uuid' import type { GuiExchangeRates } from '../actions/ExchangeRateActions' -import { - FEE_ALERT_THRESHOLD, - FEE_COLOR_THRESHOLD, - FIAT_CODES_SYMBOLS, - FIAT_PRECISION, - getFiatSymbol -} from '../constants/WalletAndCurrencyConstants' import { toLocaleDate, toLocaleDateTime, @@ -33,8 +23,19 @@ import { lstrings } from '../locales/strings' import { convertCurrency, getExchangeRate } from '../selectors/WalletSelectors' import type { RootState } from '../types/reduxTypes' import type { GuiFiatType } from '../types/types' -import { getCurrencyCode } from './CurrencyInfoHelpers' import { base58 } from './encoding' +import { + FEE_ALERT_THRESHOLD, + FEE_COLOR_THRESHOLD, + FIAT_CODES_SYMBOLS, + FIAT_PRECISION, + getFiatSymbol, + removeIsoPrefix +} from './fiatConstants' +import { currencyCodeForToken } from './txDisplay/currencyCodes' + +// Re-export so existing importers of removeIsoPrefix from utils stay unchanged +export { removeIsoPrefix } export const DECIMAL_PRECISION = 18 export const DEFAULT_TRUNCATE_PRECISION = 6 @@ -362,7 +363,7 @@ export const getTotalFiatAmountFromExchangeRates = ( ) for (const tokenId of wallet.balanceMap.keys()) { const nativeBalance = wallet.balanceMap.get(tokenId) ?? '0' - const currencyCode = getCurrencyCode(wallet, tokenId) + const currencyCode = currencyCodeForToken(wallet, tokenId) const rate = getExchangeRate( exchangeRates, wallet.currencyInfo.pluginId, @@ -427,62 +428,56 @@ export async function asyncWaterfall( ): Promise { let pending = asyncFuncs.length const promises: Array> = [] - for (const func of asyncFuncs) { - const index = promises.length - promises.push( - func().catch((e: unknown) => { - ;(e as any).index = index - throw e - }) - ) - if (pending > 1) { + + // Each stagger timer outlives the race it loses: dropping the promise with + // `promises.pop()` leaves its `setTimeout` armed for the rest of + // `timeoutMs`, which keeps Node's event loop alive after the winning + // request has already answered. Behind a daemon that delays an exit, and in + // a jest worker it reads as a leaked handle that hides the next real one. + const staggerTimers: Array> = [] + try { + for (const func of asyncFuncs) { + const index = promises.length promises.push( - new Promise(resolve => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - snooze(timeoutMs).then(() => { - resolve('async_waterfall_timed_out') - }) + func().catch((e: unknown) => { + ;(e as any).index = index + throw e }) ) - } - try { - const result = await Promise.race(promises) - if (result === 'async_waterfall_timed_out') { + if (pending > 1) { + promises.push( + new Promise(resolve => { + staggerTimers.push( + setTimeout(() => { + resolve('async_waterfall_timed_out') + }, timeoutMs) + ) + }) + ) + } + try { + const result = await Promise.race(promises) + if (result === 'async_waterfall_timed_out') { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + promises.pop() + --pending + } else { + return result + } + } catch (e: any) { + const i = e.index + // eslint-disable-next-line @typescript-eslint/no-floating-promises + promises.splice(i, 1) // eslint-disable-next-line @typescript-eslint/no-floating-promises promises.pop() --pending - } else { - return result - } - } catch (e: any) { - const i = e.index - // eslint-disable-next-line @typescript-eslint/no-floating-promises - promises.splice(i, 1) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - promises.pop() - --pending - if (pending === 0) { - throw e + if (pending === 0) { + throw e + } } } - } -} - -export async function openLink(url: string): Promise { - if (Platform.OS === 'ios') { - try { - await SafariView.isAvailable() - await SafariView.show({ url }) - return - } catch (e: any) { - console.log(e) - } - } - const supported = await Linking.canOpenURL(url) - if (supported) { - await Linking.openURL(url) - } else { - throw new Error(`Don't know how to open URI: ${url}`) + } finally { + for (const timer of staggerTimers) clearTimeout(timer) } } @@ -782,21 +777,6 @@ export const darkenHexColor = ( return scaledHexColor } -/** - * Reads and normalizes the OS version. - */ -export function getOsVersion(): string { - const osVersionRaw = DeviceInfo.getSystemVersion() - return Array.from({ length: 3 }, (_, i) => { - const part = osVersionRaw.split('.')[i] - return part != null && part !== '' ? part : '0' - }).join('.') -} - -export const removeIsoPrefix = (currencyCode: string): string => { - return currencyCode.replace('iso:', '') -} - export const getDisplayUsername = ( loginId: string, username?: string From 934b7c7eec7b6c1337b6557bc694f74a6cfb1879 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 04/24] Split exchange rates for Node safety Exchange rates could not be fetched outside the app: the module read its server list and its error reporter at load time, both of which come from React Native. The query logic now takes what it needs through `configureExchangeRates`, and `exchangeRatesGui` supplies the GUI's Airship reporter at app start. A Node caller supplies its own, so the same rate lookup answers the same way in both places. --- src/__tests__/exchangeRates.test.ts | 4 +- src/__tests__/util/exchangeRatesCache.test.ts | 217 ++++++++++ src/app.ts | 1 + src/util/exchangeRates.ts | 373 ++++++++++++------ src/util/exchangeRatesGui.ts | 17 + 5 files changed, 495 insertions(+), 117 deletions(-) create mode 100644 src/__tests__/util/exchangeRatesCache.test.ts create mode 100644 src/util/exchangeRatesGui.ts diff --git a/src/__tests__/exchangeRates.test.ts b/src/__tests__/exchangeRates.test.ts index 8cd666a23b6..ed2645d85ee 100644 --- a/src/__tests__/exchangeRates.test.ts +++ b/src/__tests__/exchangeRates.test.ts @@ -1,7 +1,7 @@ import { beforeAll, expect, it, jest } from '@jest/globals' import fetch from 'node-fetch' -import { getHistoricalCryptoRate } from '../util/exchangeRates' +import { getHistoricalCryptoRate, stopRateQueue } from '../util/exchangeRates' import { mswServer } from '../util/mswServer' import { snooze } from '../util/utils' @@ -16,6 +16,8 @@ beforeAll(() => { mswServer.listen() }) afterAll(() => { + // A debounce left armed in a shared jest worker hides the next real leak. + stopRateQueue() mswServer.close() }) diff --git a/src/__tests__/util/exchangeRatesCache.test.ts b/src/__tests__/util/exchangeRatesCache.test.ts new file mode 100644 index 00000000000..8c4a9617484 --- /dev/null +++ b/src/__tests__/util/exchangeRatesCache.test.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals' + +import { + clearRateCache, + getHistoricalCryptoRate, + rateCacheSize, + stopRateQueue +} from '../../util/exchangeRates' + +// Real timers: the queue debounces by FETCH_FREQUENCY before it fetches. +// The injected `doFetch` below is the only thing keeping this suite off +// `rates.edge.app` — `fetchRates` falls back to the real fetch without it, so +// a case added with no fake goes to the network. +beforeAll(() => { + jest.useRealTimers() +}) +afterAll(() => { + // The debounce is `unref`ed, but an armed timer in a shared worker still + // hides the next real leak. + stopRateQueue() + jest.useFakeTimers() +}) + +/** A rates server that prices everything it is asked for. */ +let posts = 0 +const fakeFetch: any = async (_uri: string, opts: any) => { + posts++ + const body = JSON.parse(opts.body) + return { + ok: true, + status: 200, + json: async () => ({ + targetFiat: body.targetFiat, + crypto: body.crypto.map((c: any) => ({ ...c, rate: 30000 })), + fiat: body.fiat.map((f: any) => ({ ...f, rate: 1 })) + }), + text: async () => '' + } +} + +/** A rates server that answers, but prices nothing it was asked for. */ +const unpricedFetch: any = async (_uri: string, opts: any) => { + posts++ + const body = JSON.parse(opts.body) + return { + ok: true, + status: 200, + json: async () => ({ + targetFiat: body.targetFiat, + crypto: body.crypto.map((c: any) => ({ ...c, rate: undefined })), + fiat: body.fiat.map((f: any) => ({ ...f, rate: undefined })) + }), + text: async () => '' + } +} + +/** A rates server that is down. */ +const failingFetch: any = async () => { + posts++ + return { + ok: false, + status: 503, + json: async () => ({}), + text: async () => 'rates server unavailable' + } +} + +const rateFor = async ( + date: string, + doFetch: any = fakeFetch, + pluginId = 'bitcoin' +): Promise => + await getHistoricalCryptoRate(pluginId, null, 'iso:USD', date, 100, doFetch) + +describe('the module-level rate cache', () => { + it('caches a rate, serves it without a second request, and clears', async () => { + clearRateCache() + expect(rateCacheSize()).toBe(0) + + const date = '2022-06-01T04:00:00.000Z' + const first = await rateFor(date) + expect(first).toBe(30000) + expect(rateCacheSize()).toBe(1) + + const before = posts + const second = await rateFor(date) + expect(second).toBe(30000) + // Served from the cache: behind a daemon this is what keeps a long + // listing from re-pricing the same dates. + expect(posts).toBe(before) + + // And a long-lived process needs to be able to drop it: the engine calls + // this when the last session goes away and again on shutdown. + clearRateCache() + expect(rateCacheSize()).toBe(0) + }) + + it('does not cache a zero, so one unpriced response is not permanent', async () => { + clearRateCache() + const date = '2019-03-03T04:00:00.000Z' + + // The route publishes `0` for a rate the server cannot supply. + expect(await rateFor(date, unpricedFetch)).toBe(0) + expect(rateCacheSize()).toBe(0) + + // A later pass against a recovered server therefore prices it. + expect(await rateFor(date)).toBe(30000) + expect(rateCacheSize()).toBe(1) + }) + + it('resolves every caller with zero when the server fails, caching none', async () => { + clearRateCache() + const rates = await Promise.all([ + rateFor('2019-04-01T04:00:00.000Z', failingFetch), + rateFor('2019-04-02T04:00:00.000Z', failingFetch) + ]) + expect(rates).toStrictEqual([0, 0]) + expect(rateCacheSize()).toBe(0) + }) + + it('prices a key queued while a request is already in flight', async () => { + clearRateCache() + posts = 0 + + // `addToQueue` deliberately arms no second timer while a pass is running, + // so this key joins the queue without a request of its own. Deciding "no + // progress" from the queue's size made the in-flight pass resolve it as + // `0` without ever asking the server — a $0 fiat amount in an export and + // in a transaction-list row. + let release: (value: unknown) => void = () => {} + const held = new Promise(resolve => { + release = resolve + }) + const slowFetch: any = async (uri: string, opts: any) => { + await held + return await fakeFetch(uri, opts) + } + + const first = rateFor('2020-01-01T04:00:00.000Z', slowFetch) + // Long enough for the debounce to fire and the fetch to be outstanding. + await new Promise(resolve => setTimeout(resolve, 1200)) + const second = rateFor('2020-01-02T04:00:00.000Z', fakeFetch) + const third = rateFor('2020-01-03T04:00:00.000Z', fakeFetch) + release(undefined) + + expect(await first).toBe(30000) + expect(await second).toBe(30000) + expect(await third).toBe(30000) + // Two passes: the one that was in flight, and one for the arrivals. + expect(posts).toBe(2) + }) + + it('gives up once on keys the server answered but could not price', async () => { + clearRateCache() + posts = 0 + // Without settling an asked-for key the retry would re-send the identical + // request forever, with no delay. + expect(await rateFor('2018-07-07T04:00:00.000Z', unpricedFetch)).toBe(0) + expect(posts).toBe(1) + }) + + it('keeps the cache bounded', async () => { + clearRateCache() + // One date per entry, past the 20,000 bound. Dates are a day apart so + // every key is distinct. They are queued before anything is awaited, so + // the whole set costs one debounce and then one immediate pass per + // RATES_SERVER_MAX_QUERY_SIZE batch. + const start = Date.UTC(2000, 0, 1) + const day = 86_400_000 + const pending: Array> = [] + for (let i = 0; i < 20_100; i++) { + pending.push(rateFor(new Date(start + i * day).toISOString())) + } + const rates = await Promise.all(pending) + expect(rates.every(rate => rate === 30000)).toBe(true) + expect(rateCacheSize()).toBeLessThanOrEqual(20_000) + expect(rateCacheSize()).toBeGreaterThan(0) + }, 120_000) +}) + +describe('query scheduling', () => { + it('fires immediately when the queue has been idle', async () => { + // A trailing-edge debounce spent a full FETCH_FREQUENCY before the first + // request, and every daemon request starts from an idle queue — so + // `get-transactions` paid it twice in series, a fixed ~2s floor + // independent of page size, and ~1s even when every rate was cached. + stopRateQueue() + clearRateCache() + posts = 0 + + const started = Date.now() + await rateFor('2024-01-01T00:00:00.000Z') + const elapsed = Date.now() - started + + // Well under FETCH_FREQUENCY, which is what the old path could not do. + expect(elapsed).toBeLessThan(500) + expect(posts).toBe(1) + }) + + it('still collapses a burst into one request', async () => { + // The coalescing the GUI's per-row lookups need: the leading edge is + // deferred by a tick, so everything queued in the same pass goes in one + // batch rather than one request per caller. + stopRateQueue() + clearRateCache() + posts = 0 + + const dates = Array.from( + { length: 25 }, + (_v, i) => `2024-02-${String(i + 1).padStart(2, '0')}T00:00:00.000Z` + ) + await Promise.all(dates.map(async date => await rateFor(date))) + + expect(rateCacheSize()).toBe(25) + expect(posts).toBe(1) + }) +}) diff --git a/src/app.ts b/src/app.ts index 97ebd67d44d..d7c8ca32b81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import './locales/initLocale' // Metro's default `inlineRequires` can defer the whole module — so the one // wiring that decides how rate-query failures are reported was the one whose // evaluation point depended on a bundler transform. +import './util/exchangeRatesGui' import NetInfo from '@react-native-community/netinfo' import * as Sentry from '@sentry/react-native' diff --git a/src/util/exchangeRates.ts b/src/util/exchangeRates.ts index 7a3fbe99bd1..44b0351a0e3 100644 --- a/src/util/exchangeRates.ts +++ b/src/util/exchangeRates.ts @@ -1,3 +1,9 @@ +/** + * Core historical / batched rates against rates3/4 `v3/rates`. + * + * Uses Node-safe `network.fetchRates` and `utils.removeIsoPrefix`. + * The GUI wires Airship `showError` via `exchangeRatesGui.ts`. + */ import { asArray, asDate, @@ -10,7 +16,6 @@ import { } from 'cleaners' import type { EdgeFetchFunction, EdgeTokenId } from 'edge-core-js' -import { showError } from '../components/services/AirshipInstance' import { fetchRates } from './network' import { removeIsoPrefix } from './utils' @@ -20,6 +25,17 @@ const SHOW_LOGS = false const clog = SHOW_LOGS ? console.log : (...args: any) => undefined +let onQueryError: (error: unknown) => void = error => { + console.warn(error) +} + +/** GUI calls this from `exchangeRatesGui.ts` to show Airship errors. */ +export function configureExchangeRates(opts: { + onError?: (error: unknown) => void +}): void { + if (opts.onError != null) onQueryError = opts.onError +} + // From rates server: export const asCryptoAsset = asObject({ pluginId: asString, @@ -42,9 +58,39 @@ export const asRatesParams = asObject({ }) export type RatesParams = ReturnType -type RateMap = Record +/** + * The asset a crypto rate names, as one index key. + * + * Settling a queued entry against a response used to be + * `data.crypto.some(sameCrypto)` inside a walk of the entire queue, so a + * caller that queues a whole transaction history at once paid + * `O(queued x requested)` per pass. The response is indexed by these keys + * instead, which makes each settle a lookup. + */ +const cryptoIndexKey = (entry: { + isoDate?: Date + asset: { pluginId: string; tokenId?: EdgeTokenId } +}): string => + `${entry.asset.pluginId}|${entry.asset.tokenId ?? ''}|${ + entry.isoDate?.getTime() ?? '' + }` + +const fiatIndexKey = (entry: { isoDate?: Date; fiatCode: string }): string => + `${entry.fiatCode}|${entry.isoDate?.getTime() ?? ''}` + +/** + * How many rates the module-level cache keeps. + * + * In the GUI this was an app-session cache; behind a daemon it is a process + * cache, and `fillTxsFiat` adds one entry per transaction date of every + * wallet of every account the engine ever serves. An engine started with + * `--idle-timeout=0` never exits, so the map needs a bound as well as a + * clear function. + */ +const RATE_CACHE_MAX = 20_000 -const rateMap: RateMap = {} +// A Map, for insertion-ordered eviction and a real `size`. +const rateMap = new Map() const resolverMap = new Map< string, { @@ -53,149 +99,174 @@ const resolverMap = new Map< } >() let inQuery = false +/** + * When the last query finished, for the leading-edge debounce. + * + * A trailing-edge debounce spends a full `FETCH_FREQUENCY` before the *first* + * request, which is the worst case for a daemon: every engine request starts + * from an idle queue, so `get-transactions` paid it twice in series — once + * for the spam threshold and once for the fiat fill — a fixed ~2s floor + * independent of page size, and ~1s even when every rate was already cached. + */ +let lastQueryEndedAt = 0 +let queryTimer: ReturnType | undefined + +/** + * Keep a pending debounce from holding a process open. + * + * Node's timer has `unref`; React Native's is a number and has none. A + * debounce should never be the reason a program stays alive — the engine + * holds a listening socket and the GUI a running app — and in a jest worker + * an armed timer is a leaked handle that outlives the test. + */ +function unrefTimer(timer: unknown): void { + if ( + typeof timer === 'object' && + timer != null && + typeof (timer as { unref?: unknown }).unref === 'function' + ) { + ;(timer as { unref: () => void }).unref() + } +} + +/** One upstream request: its parameters, and the queue keys it speaks for. */ +interface RateQueryGroup { + params: RatesParams + keys: string[] +} let numDoQuery = 0 const doQuery = async (doFetch?: EdgeFetchFunction): Promise => { const n = numDoQuery++ clog(`${n} doQuery enter`) - const groupedParams = new Map() + const groups = new Map() // Fill the query up to RATES_SERVER_MAX_QUERY_SIZE entries - const values = resolverMap.values() - for (const value of values) { - const map = groupedParams.get(value.rateQueueEntry.targetFiat) - if (map == null) { - groupedParams.set(value.rateQueueEntry.targetFiat, value.rateQueueEntry) - } else { - const combinedMap: RatesParams = { - targetFiat: map.targetFiat, - crypto: [...map.crypto, ...value.rateQueueEntry.crypto], - fiat: [...map.fiat, ...value.rateQueueEntry.fiat] - } - groupedParams.set(value.rateQueueEntry.targetFiat, combinedMap) + for (const [key, value] of resolverMap.entries()) { + const group = groups.get(value.rateQueueEntry.targetFiat) + if (group == null) { + groups.set(value.rateQueueEntry.targetFiat, { + params: value.rateQueueEntry, + keys: [key] + }) + continue } - if (map?.crypto.length === RATES_SERVER_MAX_QUERY_SIZE) break + // The server rejects a request whose `crypto` or `fiat` array reaches + // RATES_SERVER_MAX_QUERY_SIZE ("must be less than 100"), so this stops + // *before* adding an entry that would cross it. Testing the pre-insert + // length for equality instead let a batch reach 101 and let a batch that + // stepped over 100 never stop at all; whatever is left over is picked up + // by the retry at the end of this function. + if ( + group.params.crypto.length + value.rateQueueEntry.crypto.length >= + RATES_SERVER_MAX_QUERY_SIZE || + group.params.fiat.length + value.rateQueueEntry.fiat.length >= + RATES_SERVER_MAX_QUERY_SIZE + ) { + break + } + + group.params = { + targetFiat: group.params.targetFiat, + crypto: [...group.params.crypto, ...value.rateQueueEntry.crypto], + fiat: [...group.params.fiat, ...value.rateQueueEntry.fiat] + } + group.keys.push(key) } - for (const data of groupedParams.values()) { + for (const group of groups.values()) { const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify(group.params) } try { const response = await fetchRates('v3/rates', options, 5000, doFetch) - if (response.ok) { - const json = await response.json() - const cleanedRates = asRatesParams(json) - - // Since the requests are USD only, we'll match up the original requests to what we've received - for (const [ - key, - { rateQueueEntry, resolvers } - ] of resolverMap.entries()) { - // Match crypto/fiat requests - if (rateQueueEntry.crypto.length === 1) { - const cryptoToMatch = rateQueueEntry.crypto[0] - const fiatToMatch = rateQueueEntry.fiat[0] - const crypto = cleanedRates.crypto.find(cr => { - return ( - cr.isoDate?.getTime() === cryptoToMatch.isoDate?.getTime() && - cr.asset.pluginId === cryptoToMatch.asset.pluginId && - (cr.asset.tokenId === cryptoToMatch.asset.tokenId || - (cr.asset.tokenId == null && - cryptoToMatch.asset.tokenId == null)) - ) - }) - - const fiat = cleanedRates.fiat.find(fr => { - return ( - fr.isoDate?.getTime() === fiatToMatch.isoDate?.getTime() && - fr.fiatCode === fiatToMatch.fiatCode - ) - }) - - if (crypto?.rate != null && fiat?.rate != null) { - const rate = crypto.rate / fiat.rate - rateMap[key] = rate - if (resolverMap.get(key) == null) { - clog(`${n} oops`) - continue - } - - clog(`${n} deleting ${key}`) - resolverMap.delete(key) - if (resolvers.length) { - resolvers.forEach((r, i) => { - r(rate) - }) - } - } - } + if (!response.ok) { + const text = await response.text() + throw new Error(text) + } + const cleanedRates = asRatesParams(await response.json()) - // Match fiat/fiat requests - if (rateQueueEntry.fiat.length === 2) { - const fromFiatToMatch = rateQueueEntry.fiat[0] - const fiatToMatch = rateQueueEntry.fiat[1] - const fromFiat = cleanedRates.fiat.find(fr => { - return ( - fr.isoDate?.getTime() === fromFiatToMatch.isoDate?.getTime() && - fr.fiatCode === fromFiatToMatch.fiatCode - ) - }) - - const toFiat = cleanedRates.fiat.find(fr => { - return ( - fr.isoDate?.getTime() === fiatToMatch.isoDate?.getTime() && - fr.fiatCode === fiatToMatch.fiatCode - ) - }) - - if (fromFiat?.rate != null && toFiat?.rate != null) { - const rate = fromFiat.rate / toFiat.rate - rateMap[key] = rate - if (resolverMap.get(key) == null) { - clog(`${n} oops`) - continue - } - - clog(`${n} deleting ${key}`) - resolverMap.delete(key) - if (resolvers.length) { - resolvers.forEach((r, i) => { - r(rate) - }) - } - } + // One index per response, so settling a key costs a lookup rather than + // a scan of both request arrays. + const cryptoRates = new Map() + for (const rate of cleanedRates.crypto) { + cryptoRates.set(cryptoIndexKey(rate), rate.rate) + } + const fiatRates = new Map() + for (const rate of cleanedRates.fiat) { + fiatRates.set(fiatIndexKey(rate), rate.rate) + } + + // Only the keys this request carried. A key queued while the request + // was in flight was never asked about, so it is none of this response's + // business and gets a pass of its own below. Settling those here + // answered `0` for a rate the server could have priced. + for (const key of group.keys) { + const pending = resolverMap.get(key) + if (pending == null) continue + const { rateQueueEntry, resolvers } = pending + + // The server answered, so this key settles either way: at `0` when it + // came back unpriced, because a key that survives its own response + // would make the retry below spin forever. + let rate = 0 + if (rateQueueEntry.crypto.length === 1) { + const cryptoRate = cryptoRates.get( + cryptoIndexKey(rateQueueEntry.crypto[0]) + ) + const fiatRate = fiatRates.get(fiatIndexKey(rateQueueEntry.fiat[0])) + if (cryptoRate != null && fiatRate != null && fiatRate !== 0) { + rate = cryptoRate / fiatRate + } + } else if (rateQueueEntry.fiat.length === 2) { + const fromRate = fiatRates.get(fiatIndexKey(rateQueueEntry.fiat[0])) + const toRate = fiatRates.get(fiatIndexKey(rateQueueEntry.fiat[1])) + if (fromRate != null && toRate != null && toRate !== 0) { + rate = fromRate / toRate } } - } else { - const text = await response.text() - throw new Error(text) + + // `0` means "the server could not price this", not a rate. Caching it + // would answer `0` for that key for the life of the process, long + // after the rates server recovered. + if (rate !== 0) cacheRate(key, rate) + clog(`${n} deleting ${key}`) + resolverMap.delete(key) + resolvers.forEach(r => r(rate)) } } catch (e: unknown) { if (e instanceof Error) { console.warn(`Error querying rates server ${e.message}`) } - // Resolve all the promises with value 0 - const resolversMapEntries = [...resolverMap.entries()] - resolversMapEntries.forEach(entry => { - const [pairDate, value] = entry - clog(`${n} throw deleting ${pairDate}`) - resolverMap.delete(pairDate) - value.resolvers.forEach(resolve => resolve(0)) - }) + // Give up on this request's own keys only. The queue may also hold keys + // another group owns and keys that arrived mid-flight, and neither was + // part of this failure. + for (const key of group.keys) { + const pending = resolverMap.get(key) + if (pending == null) continue + clog(`${n} throw deleting ${key}`) + resolverMap.delete(key) + pending.resolvers.forEach(resolve => resolve(0)) + } } } + // Every key this pass asked about has settled, so whatever is left was + // never sent: the RATES_SERVER_MAX_QUERY_SIZE remainder, or an arrival from + // during the round trip, which `addToQueue` deliberately does not arm a + // second timer for. Each pass therefore removes at least the keys it asked + // about, so this terminates. if (resolverMap.size > 0) { clog(`${n} Calling doQuery again`) await doQuery(doFetch) } else { clog(`${n} doQuery complete`) inQuery = false + lastQueryEndedAt = Date.now() } } @@ -205,7 +276,7 @@ const addToQueue = ( resolve: Function, maxQuerySize: number, doFetch?: EdgeFetchFunction -) => { +): void => { const rateKeyResolver = resolverMap.get(rateKey) if (rateKeyResolver == null) { // Create a new entry in the map for this pair/date @@ -221,14 +292,84 @@ const addToQueue = ( } if (!inQuery) { inQuery = true - setTimeout(() => { + // Leading edge when the queue has been idle at least `FETCH_FREQUENCY`, + // and otherwise only far enough out to keep that as the minimum spacing + // between requests. Deferred by a tick rather than run synchronously, so + // a burst of per-row lookups still collapses into one batch, and anything + // arriving during the round trip is coalesced by `doQuery`'s own + // recursion — which is what keeps a burst to a bounded number of requests + // rather than one per caller. + const idleFor = Date.now() - lastQueryEndedAt + const delay = idleFor >= FETCH_FREQUENCY ? 0 : FETCH_FREQUENCY - idleFor + queryTimer = setTimeout(() => { + queryTimer = undefined doQuery(doFetch).catch((error: unknown) => { - showError(error) + onQueryError(error) }) - }, FETCH_FREQUENCY) + }, delay) + unrefTimer(queryTimer) + } +} + +/** + * Remember a rate, evicting the oldest entries once the map is full. + * + * A Map iterates in insertion order, so the oldest entries go first: a long + * listing pages through dates in order, and the earliest are the ones least + * likely to be asked for again. + */ +function cacheRate(key: string, rate: number): void { + if (!rateMap.has(key) && rateMap.size >= RATE_CACHE_MAX) { + // A quarter at a time rather than one per insert, so a long listing does + // not pay an eviction on every transaction. + const victims = [...rateMap.keys()].slice(0, RATE_CACHE_MAX >> 2) + for (const victim of victims) rateMap.delete(victim) + } + rateMap.set(key, rate) +} + +/** + * Drop every cached rate. + * + * A module-level cache in a long-lived process needs an owner: the engine + * calls this when the last session goes away and again on shutdown, so a + * daemon does not accumulate one entry per transaction date for the life of + * the machine. + */ +export function clearRateCache(): void { + rateMap.clear() +} + +/** + * Stop the pending query and settle whatever is still queued. + * + * `clearRateCache` empties the cache, which says nothing about work in + * flight: a debounce armed just before shutdown would otherwise fire against + * a closing context, and anything awaiting a rate would never settle. The + * engine calls this from `shutdown`, and a test calls it so no timer outlives + * the suite. + */ +export function stopRateQueue(): void { + if (queryTimer != null) { + clearTimeout(queryTimer) + queryTimer = undefined + } + inQuery = false + // So the next query after a stop is not throttled against a pass that + // never ran. A test that stops the queue between cases wants the same + // starting point each time. + lastQueryEndedAt = 0 + for (const [key, { resolvers }] of [...resolverMap.entries()]) { + resolverMap.delete(key) + resolvers.forEach(resolve => resolve(0)) } } +/** How many rates are cached. `engine-status` reports it as `rateCachedCount`. */ +export function rateCacheSize(): number { + return rateMap.size +} + const createRateKey = ( asset: { pluginId: string; tokenId?: EdgeTokenId } | string, targetFiat: string, @@ -321,7 +462,7 @@ const getHistoricalRate = async ( doFetch?: EdgeFetchFunction ): Promise => { return await new Promise((resolve, reject) => { - const rate = rateMap[rateKey] + const rate = rateMap.get(rateKey) if (rate == null) { addToQueue(RatesParams, rateKey, resolve, maxQuerySize, doFetch) return diff --git a/src/util/exchangeRatesGui.ts b/src/util/exchangeRatesGui.ts new file mode 100644 index 00000000000..f19abd689a6 --- /dev/null +++ b/src/util/exchangeRatesGui.ts @@ -0,0 +1,17 @@ +/** + * GUI wiring for historical rates. Import once at app startup so + * `exchangeRates.ts` reports queue errors via Airship. + * + * Call sites keep importing helpers from `./exchangeRates`. + */ +import { showError } from '../components/services/AirshipInstance' +import { configureExchangeRates } from './exchangeRates' + +configureExchangeRates({ + // Wrapped, not passed bare: a bare reference makes `exchangeRates.ts`'s own + // `onQueryError(error)` the reporting frame for every caller, so this file + // — the one a reader would blame — appears nowhere in the stack. + onError: (error: unknown) => { + showError(error) + } +}) From c23b2d8fda67a83816cb7344107640fed72aac1c Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 05/24] Extract Node-safe locale detection `initLocale` reaches for `react-native-localize`, so nothing outside the app could ask which locale to use. The decision itself is pure: read a tag from argv, config or the environment, normalize it, and pick a language table. `nodeLocale.ts` holds that decision with no React Native imports, and feeds the same `applyLocale` that the GUI's device lookup already calls, so the GUI and any Node caller resolve a locale the same way rather than approximately the same way. Precedence is explicit and tested: an explicit tag, then config, then `EDGE_CLI_LOCALE`, then `LC_ALL` / `LC_MESSAGES` / `LANG`, then `Intl`, then `en-US`. `es_MX.UTF-8@euro` and `C` both resolve, which is what the POSIX forms actually look like. `env` is typed as the variables it reads rather than `NodeJS.ProcessEnv`, which in this repo demands `NODE_ENV` and would make every caller invent one. --- src/__tests__/nodeLocale.test.ts | 100 +++++++++++++ src/__tests__/util/intlLocaleFallback.test.ts | 44 ++++++ src/locales/nodeLocale.ts | 132 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 src/__tests__/nodeLocale.test.ts create mode 100644 src/__tests__/util/intlLocaleFallback.test.ts create mode 100644 src/locales/nodeLocale.ts diff --git a/src/__tests__/nodeLocale.test.ts b/src/__tests__/nodeLocale.test.ts new file mode 100644 index 00000000000..dbf3067db36 --- /dev/null +++ b/src/__tests__/nodeLocale.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from '@jest/globals' + +import { applyLocale } from '../locales/bootLocale' +import { + detectNodeLocale, + localeTagsMatch, + normalizePosixLocale, + numberSeparators, + parseLocaleFlag +} from '../locales/nodeLocale' +import { lstrings, selectLocale } from '../locales/strings' + +describe('normalizePosixLocale', () => { + test('strips encoding and modifier', () => { + expect(normalizePosixLocale('es_MX.UTF-8@euro')).toBe('es-MX') + }) + test('C and POSIX become en-US', () => { + expect(normalizePosixLocale('C')).toBe('en-US') + expect(normalizePosixLocale('POSIX')).toBe('en-US') + expect(normalizePosixLocale('')).toBe('en-US') + }) + test('keeps hyphenated tags', () => { + expect(normalizePosixLocale('de-DE')).toBe('de-DE') + }) +}) + +describe('parseLocaleFlag', () => { + test('reads --locale value', () => { + expect(parseLocaleFlag(['--locale', 'fr'])).toBe('fr') + }) + test('reads --locale=', () => { + expect(parseLocaleFlag(['--locale=ja'])).toBe('ja') + }) +}) + +describe('detectNodeLocale', () => { + test('argv wins over env', () => { + const source = detectNodeLocale({ + argv: ['--locale', 'de-DE'], + env: { LANG: 'fr_FR.UTF-8', EDGE_CLI_LOCALE: 'es' } + }) + expect(source.languageTag).toBe('de-DE') + }) + test('config wins over EDGE_CLI_LOCALE', () => { + const source = detectNodeLocale({ + argv: [], + env: { EDGE_CLI_LOCALE: 'ja' }, + configLocale: 'it' + }) + expect(source.languageTag).toBe('it') + }) + test('LANG es_MX.UTF-8', () => { + const source = detectNodeLocale({ + argv: [], + env: { LANG: 'es_MX.UTF-8' } + }) + expect(source.languageTag).toBe('es-MX') + }) +}) + +describe('numberSeparators', () => { + test('de-DE uses comma decimal', () => { + const seps = numberSeparators('de-DE') + expect(seps.decimalSeparator).toBe(',') + expect(seps.groupingSeparator).toBe('.') + }) +}) + +describe('selectLocale', () => { + afterEach(() => { + selectLocale('en') + applyLocale({ + languageTag: 'en-US', + decimalSeparator: '.', + groupingSeparator: ',' + }) + }) + + test('de changes a known string', () => { + const english = lstrings.action_queue_display_unknown_message + const matched = selectLocale('de') + expect(matched).toBe(true) + expect(lstrings.action_queue_display_unknown_message).not.toBe(english) + }) + + test('zh_CN falls back to zh', () => { + expect(selectLocale('zh-CN')).toBe(true) + }) + + test('es-MX matches esMX table', () => { + expect(selectLocale('es-MX')).toBe(true) + }) +}) + +describe('localeTagsMatch', () => { + test('hyphen vs underscore', () => { + expect(localeTagsMatch('en-US', 'en_US')).toBe(true) + expect(localeTagsMatch('de', 'fr')).toBe(false) + }) +}) diff --git a/src/__tests__/util/intlLocaleFallback.test.ts b/src/__tests__/util/intlLocaleFallback.test.ts new file mode 100644 index 00000000000..26880b0425f --- /dev/null +++ b/src/__tests__/util/intlLocaleFallback.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from '@jest/globals' + +import { + getLocaleOrDefaultString, + setIntlLocale, + truncateDecimals +} from '../../locales/intl' + +const localized = { de: 'Deutsch', en: 'English' } + +describe('setIntlLocale', () => { + it('keeps the language when only the number format is unusable', () => { + // An OS that reports an empty separator says nothing about the language. + // Discarding the whole locale made `getLocaleOrDefaultString` resolve + // info-server strings in English on such a device, where only number + // formatting used to fall back. + setIntlLocale({ + localeIdentifier: 'de_DE', + decimalSeparator: '', + groupingSeparator: '' + }) + expect(getLocaleOrDefaultString(localized)).toBe('Deutsch') + // And the number format really is the English one. + expect(truncateDecimals('1.23456', 2)).toBe('1.23') + }) + + it('falls back entirely when the language itself is unusable', () => { + setIntlLocale({ + localeIdentifier: '', + decimalSeparator: ',', + groupingSeparator: '.' + }) + expect(getLocaleOrDefaultString(localized)).toBe('English') + }) + + it('takes a complete locale as given', () => { + setIntlLocale({ + localeIdentifier: 'de_DE', + decimalSeparator: ',', + groupingSeparator: '.' + }) + expect(getLocaleOrDefaultString(localized)).toBe('Deutsch') + }) +}) diff --git a/src/locales/nodeLocale.ts b/src/locales/nodeLocale.ts new file mode 100644 index 00000000000..176e334f859 --- /dev/null +++ b/src/locales/nodeLocale.ts @@ -0,0 +1,132 @@ +/** + * CLI locale detection. No react-native. + */ +import type { LocaleSource } from './bootLocale' + +/** + * The environment variables this reads, and nothing else. + * + * `process.env` satisfies it, but so does `{ LANG: 'es_MX.UTF-8' }`. Typing + * this as `NodeJS.ProcessEnv` demanded `NODE_ENV` from every caller, which no + * locale test has any reason to set. + */ +export type LocaleEnv = Readonly> + +export interface DetectNodeLocaleOpts { + argv?: string[] + env?: LocaleEnv + configLocale?: string +} + +/** + * POSIX / BCP-47 tag → hyphenated language tag for Intl and selectLocale. + * `es_MX.UTF-8@euro` → `es-MX`; `C` / `POSIX` / empty → `en-US`. + */ +export function normalizePosixLocale(raw: string): string { + const trimmed = raw.trim() + if (trimmed === '' || trimmed === 'C' || trimmed === 'POSIX') return 'en-US' + const noModifier = trimmed.split('@')[0] ?? trimmed + const noEncoding = noModifier.split('.')[0] ?? noModifier + const hyphenated = noEncoding.replace(/_/g, '-') + return hyphenated === '' ? 'en-US' : hyphenated +} + +/** + * The value of one flag in argv, in either spelling, or undefined. + * + * Deliberately lenient and deliberately separate from the real parsers in + * `src/cli/parseArgs.ts` and `src/cli/engine/index.ts`: this runs *before* + * either of them, from the locale boot, where a usage error has nowhere to + * go and the only sensible answer to an unreadable flag is the default. The + * real parser reports it a moment later. + * + * `parseLocaleFlag` and `parseConfigPathFlag` were byte-identical apart from + * the names they matched. + */ +function parseEarlyFlag( + argv: string[], + long: string, + short?: string +): string | undefined { + const equals = `${long}=` + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === long || (short != null && a === short)) { + const next = argv[i + 1] + if (next == null || next.startsWith('-')) return undefined + return next + } + if (a.startsWith(equals)) { + const value = a.slice(equals.length) + return value === '' ? undefined : value + } + } + return undefined +} + +export function parseLocaleFlag(argv: string[]): string | undefined { + return parseEarlyFlag(argv, '--locale') +} + +export function parseConfigPathFlag(argv: string[]): string | undefined { + return parseEarlyFlag(argv, '--config', '-c') +} + +function nonempty(value: string | undefined): string | undefined { + if (value == null) return undefined + const trimmed = value.trim() + return trimmed === '' ? undefined : trimmed +} + +function posixLanguageTag(env: LocaleEnv): string | undefined { + return nonempty(env.LC_ALL) ?? nonempty(env.LC_MESSAGES) ?? nonempty(env.LANG) +} + +export function numberSeparators(languageTag: string): { + decimalSeparator: string + groupingSeparator: string +} { + try { + const parts = new Intl.NumberFormat(languageTag, { + useGrouping: true + }).formatToParts(1234567.89) + const decimal = parts.find(part => part.type === 'decimal')?.value ?? '.' + const grouping = parts.find(part => part.type === 'group')?.value ?? ',' + if (decimal === '' || grouping === '') { + return { decimalSeparator: '.', groupingSeparator: ',' } + } + return { decimalSeparator: decimal, groupingSeparator: grouping } + } catch { + return { decimalSeparator: '.', groupingSeparator: ',' } + } +} + +/** + * Precedence: --locale, config locale, EDGE_CLI_LOCALE, LC_ALL / LC_MESSAGES / + * LANG, Intl, en-US. One tag drives language and number format. + */ +export function detectNodeLocale( + opts: DetectNodeLocaleOpts = {} +): LocaleSource { + const env = opts.env ?? process.env + const argv = opts.argv ?? [] + const raw = + nonempty(parseLocaleFlag(argv)) ?? + nonempty(opts.configLocale) ?? + nonempty(env.EDGE_CLI_LOCALE) ?? + posixLanguageTag(env) ?? + nonempty(Intl.DateTimeFormat().resolvedOptions().locale) ?? + 'en-US' + const languageTag = normalizePosixLocale(raw) + return { + languageTag, + ...numberSeparators(languageTag) + } +} + +export function localeTagsMatch(a: string, b: string): boolean { + return ( + a.replace(/[-_]/g, '').toLowerCase() === + b.replace(/[-_]/g, '').toLowerCase() + ) +} From 6256aadedc840fda20b806b3304d98f1ccdfba59 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 06/24] Extract Node-safe transaction display metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CategoriesActions.ts` held five hundred lines deciding what a transaction should be called: the category, the payee, the direction, and the label for each action type. All of it is a pure function of the transaction, the wallet and the account, but it sat behind Redux imports, so nothing outside the app could ask the same question and get the same answer. `src/util/txDisplay/` holds that logic now — `displayInfo` for the derivation, `category` for the category strings, `txActionLabels` for the action names, and `currencyCodes` for the ticker lookups. `CategoriesActions` re-exports what the GUI already imported, so no scene changed. The point is that two callers cannot drift. A transaction rendered in a list, exported to CSV, or printed by a script now describes itself identically, because it is the same code deciding. --- src/__tests__/util/txDisplay.test.ts | 293 +++++++++++ src/components/modals/CategoryModal.tsx | 10 +- .../scenes/TransactionDetailsScene.tsx | 8 +- src/components/themed/TransactionListRow.tsx | 8 +- src/util/CurrencyWalletHelpers.ts | 17 +- src/util/fiatCode.ts | 27 + src/util/txDisplay/category.ts | 68 +++ src/util/txDisplay/displayInfo.ts | 470 ++++++++++++++++++ src/util/txDisplay/index.ts | 6 + src/util/txDisplay/txActionLabels.ts | 39 ++ 10 files changed, 916 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/util/txDisplay.test.ts create mode 100644 src/util/fiatCode.ts create mode 100644 src/util/txDisplay/category.ts create mode 100644 src/util/txDisplay/displayInfo.ts create mode 100644 src/util/txDisplay/index.ts create mode 100644 src/util/txDisplay/txActionLabels.ts diff --git a/src/__tests__/util/txDisplay.test.ts b/src/__tests__/util/txDisplay.test.ts new file mode 100644 index 00000000000..09ba3c6430b --- /dev/null +++ b/src/__tests__/util/txDisplay.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from '@jest/globals' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgeTransaction +} from 'edge-core-js' + +import { joinCategory, splitCategory } from '../../util/txDisplay/category' +import { + fillTxMetadataForDisplay, + getTxActionDisplayInfo +} from '../../util/txDisplay/displayInfo' + +describe('splitCategory', () => { + it('reads each known prefix, case-insensitively', () => { + expect(splitCategory('Expense:Food')).toStrictEqual({ + category: 'expense', + subcategory: 'Food' + }) + expect(splitCategory('expense:Food')).toStrictEqual({ + category: 'expense', + subcategory: 'Food' + }) + expect(splitCategory('Transfer:')).toStrictEqual({ + category: 'transfer', + subcategory: '' + }) + expect(splitCategory('Income:Gift')).toStrictEqual({ + category: 'income', + subcategory: 'Gift' + }) + expect(splitCategory('Exchange:Swap')).toStrictEqual({ + category: 'exchange', + subcategory: 'Swap' + }) + }) + + it('matches a bare category name with no colon', () => { + expect(splitCategory('Expense')).toStrictEqual({ + category: 'expense', + subcategory: '' + }) + }) + + it('replaces an unrecognised prefix and keeps the user text whole', () => { + // Nothing of an unrecognised string is a category this code knows, and + // all of it is text a user, a dapp or `--metadata` put there. Slicing at + // the first colon destroyed it: `TransactionDetailsScene` round-trips + // this pair through `joinCategory`, so opening and saving such a + // transaction wrote the `Shopping` segment away. + expect(splitCategory('Shopping:Food')).toStrictEqual({ + category: 'income', + subcategory: 'Shopping:Food' + }) + expect(splitCategory('Foo:bar')).toStrictEqual({ + category: 'income', + subcategory: 'Foo:bar' + }) + // A bare word is the user's own text, not a category name, and used to + // come back with the internally appended colon still on it. + expect(splitCategory('plain')).toStrictEqual({ + category: 'income', + subcategory: 'plain' + }) + }) + + it('honours the default category for the fallback', () => { + expect(splitCategory('Foo:bar', 'expense')).toStrictEqual({ + category: 'expense', + subcategory: 'Foo:bar' + }) + }) + + it('takes an empty or absent category', () => { + expect(splitCategory('')).toStrictEqual({ + category: 'income', + subcategory: '' + }) + expect(splitCategory()).toStrictEqual({ + category: 'income', + subcategory: '' + }) + }) +}) + +describe('joinCategory', () => { + it('round-trips every known prefix', () => { + for (const full of [ + 'Transfer:Move', + 'Exchange:Swap', + 'Expense:Food', + 'Income:Gift' + ]) { + expect(joinCategory(splitCategory(full))).toBe(full) + } + }) + + it('capitalises the prefix the way the GUI writes it', () => { + expect(joinCategory(splitCategory('expense:food'))).toBe('Expense:food') + }) +}) + +/** + * `getTxActionDisplayInfo` reads only `wallet.id`, `wallet.currencyInfo`, + * `wallet.currencyConfig.allTokens`, `account.currencyWallets` and + * `account.currencyConfig` — that last one through + * `getCurrencyCodeWithAccount`, which is why the stub below supplies it. So + * a stub is enough and no core is needed. + */ +const wallet: EdgeCurrencyWallet = { + id: 'w1', + currencyInfo: { + pluginId: 'bitcoin', + currencyCode: 'BTC', + assetDisplayName: 'Bitcoin', + denominations: [{ name: 'BTC', multiplier: '100000000' }] + }, + currencyConfig: { + allTokens: {}, + currencyInfo: { + pluginId: 'bitcoin', + currencyCode: 'BTC', + denominations: [{ name: 'BTC', multiplier: '100000000' }] + } + }, + fiatCurrencyCode: 'iso:USD' +} as unknown as EdgeCurrencyWallet + +const account = { + currencyWallets: { w1: wallet }, + currencyConfig: { bitcoin: wallet.currencyConfig } +} as unknown as EdgeAccount + +function tx(over: Partial): EdgeTransaction { + return { + txid: 'abc', + date: 1600000000, + currencyCode: 'BTC', + tokenId: null, + nativeAmount: '-10000', + networkFee: '100', + blockHeight: 1, + isSend: true, + memos: [], + ourReceiveAddresses: [], + signedTx: '', + walletId: 'w1', + ...over + } as unknown as EdgeTransaction +} + +describe('getTxActionDisplayInfo', () => { + it('reads a plain send as an expense, and a receive as income', () => { + const sent = getTxActionDisplayInfo(tx({}), account, wallet) + expect(sent.direction).toBe('send') + expect(splitCategory(sent.mergedData.category ?? '').category).toBe( + 'expense' + ) + + const received = getTxActionDisplayInfo( + tx({ nativeAmount: '10000', isSend: false }), + account, + wallet + ) + expect(received.direction).toBe('receive') + expect(splitCategory(received.mergedData.category ?? '').category).toBe( + 'income' + ) + }) + + it('treats a zero-amount send as a send', () => { + const zero = getTxActionDisplayInfo( + tx({ nativeAmount: '0', isSend: true }), + account, + wallet + ) + expect(zero.direction).toBe('send') + }) + + it('derives an exchange category from a swap action', () => { + const swap = getTxActionDisplayInfo( + tx({ + savedAction: { + actionType: 'swap', + swapInfo: { + pluginId: 'fakeswap', + displayName: 'Fake Swap', + supportEmail: 'a@b.c' + }, + fromAsset: { pluginId: 'bitcoin', tokenId: null }, + toAsset: { pluginId: 'bitcoin', tokenId: null }, + payoutAddress: 'addr', + payoutWalletId: 'w1' + }, + assetAction: { assetActionType: 'swap' } + }), + account, + wallet + ) + expect(splitCategory(swap.mergedData.category ?? '').category).toBe( + 'exchange' + ) + expect(swap.action?.actionType).toBe('swap') + expect(swap.assetAction?.assetActionType).toBe('swap') + }) + + it('names the stake plugin for a stake action', () => { + const stake = getTxActionDisplayInfo( + tx({ + savedAction: { + actionType: 'stake', + pluginId: 'bitcoin', + stakeAssets: [{ pluginId: 'bitcoin', tokenId: null }] + }, + assetAction: { assetActionType: 'stake' } + }), + account, + wallet + ) + expect(stake.action?.actionType).toBe('stake') + expect(stake.mergedData.name).not.toBe('') + }) + + it('keeps what the user wrote over anything derived', () => { + const edited = getTxActionDisplayInfo( + tx({ + metadata: { + name: 'My payee', + notes: 'My note', + category: 'Income:Pay' + }, + savedAction: { + actionType: 'stake', + pluginId: 'bitcoin', + stakeAssets: [{ pluginId: 'bitcoin', tokenId: null }] + }, + assetAction: { assetActionType: 'stake' } + }), + account, + wallet + ) + expect(edited.mergedData.name).toBe('My payee') + expect(edited.mergedData.notes).toBe('My note') + expect(edited.mergedData.category).toBe('Income:Pay') + // The derived values are still reported separately, so a caller can show + // both. + expect(edited.userData.name).toBe('My payee') + expect(edited.savedData.name).not.toBe('My payee') + }) + + it('prefers savedAction over chainAction', () => { + const both = getTxActionDisplayInfo( + tx({ + savedAction: { + actionType: 'stake', + pluginId: 'bitcoin', + stakeAssets: [] + }, + chainAction: { + actionType: 'tokenApproval', + tokenApproved: { pluginId: 'bitcoin', tokenId: null }, + tokenContractAddress: 'a', + contractAddress: 'b' + } + }), + account, + wallet + ) + expect(both.action?.actionType).toBe('stake') + }) +}) + +describe('fillTxMetadataForDisplay', () => { + it('overlays the three display fields and keeps the rest', () => { + const original = tx({ + metadata: { exchangeAmount: { 'iso:USD': 12 }, bizId: 7, name: 'old' } + }) + const filled = fillTxMetadataForDisplay(original, { + name: 'new', + category: 'Expense:Food', + notes: 'why' + }) + expect(filled.metadata).toStrictEqual({ + exchangeAmount: { 'iso:USD': 12 }, + bizId: 7, + name: 'new', + category: 'Expense:Food', + notes: 'why' + }) + // Not persisted, and the original is untouched. + expect(original.metadata?.name).toBe('old') + }) +}) diff --git a/src/components/modals/CategoryModal.tsx b/src/components/modals/CategoryModal.tsx index 08ae7180c93..6f01d48c7cd 100644 --- a/src/components/modals/CategoryModal.tsx +++ b/src/components/modals/CategoryModal.tsx @@ -4,13 +4,10 @@ import type { AirshipBridge } from 'react-native-airship' import { FlatList } from 'react-native-gesture-handler' import { - type Category, displayCategories, formatCategory, getSubcategories, - joinCategory, - setNewSubcategory, - splitCategory + setNewSubcategory } from '../../actions/CategoriesActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { useAsyncEffect } from '../../hooks/useAsyncEffect' @@ -18,6 +15,11 @@ import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' import { useDispatch, useSelector } from '../../types/reactRedux' import { scale } from '../../util/scaling' +import { + type Category, + joinCategory, + splitCategory +} from '../../util/txDisplay' import { MinimalButton } from '../buttons/MinimalButton' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { showError } from '../services/AirshipInstance' diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..475a1a88a02 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -13,12 +13,7 @@ import FastImage from 'react-native-fast-image' import IonIcon from 'react-native-vector-icons/Ionicons' import { sprintf } from 'sprintf-js' -import { - formatCategory, - getTxActionDisplayInfo, - pluginIdIcons, - splitCategory -} from '../../actions/CategoriesActions' +import { formatCategory, pluginIdIcons } from '../../actions/CategoriesActions' import { playSendSound } from '../../actions/SoundActions' import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' import { useContactThumbnail } from '../../hooks/redux/useContactThumbnail' @@ -37,6 +32,7 @@ import type { EdgeAppSceneProps } from '../../types/routerTypes' import { getCurrencyCodeWithAccount } from '../../util/CurrencyInfoHelpers' import { matchJson } from '../../util/matchJson' import { getMemoTitle } from '../../util/memoUtils' +import { getTxActionDisplayInfo, splitCategory } from '../../util/txDisplay' import { convertNativeToExchange, darkenHexColor, diff --git a/src/components/themed/TransactionListRow.tsx b/src/components/themed/TransactionListRow.tsx index 9b4a9ca7678..e2b6ab90aa5 100644 --- a/src/components/themed/TransactionListRow.tsx +++ b/src/components/themed/TransactionListRow.tsx @@ -12,12 +12,7 @@ import Share from 'react-native-share' import Ionicons from 'react-native-vector-icons/Ionicons' import { sprintf } from 'sprintf-js' -import { - formatCategory, - getTxActionDisplayInfo, - pluginIdIcons, - splitCategory -} from '../../actions/CategoriesActions' +import { formatCategory, pluginIdIcons } from '../../actions/CategoriesActions' import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' import { useContactThumbnail } from '../../hooks/redux/useContactThumbnail' import { useDisplayDenom } from '../../hooks/useDisplayDenom' @@ -31,6 +26,7 @@ import { getExchangeDenom } from '../../selectors/DenominationSelectors' import { getExchangeRate } from '../../selectors/WalletSelectors' import { useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' +import { getTxActionDisplayInfo, splitCategory } from '../../util/txDisplay' import { DECIMAL_PRECISION, decimalOrZero, diff --git a/src/util/CurrencyWalletHelpers.ts b/src/util/CurrencyWalletHelpers.ts index 93d755d5c99..29a9eb0dfbe 100644 --- a/src/util/CurrencyWalletHelpers.ts +++ b/src/util/CurrencyWalletHelpers.ts @@ -6,7 +6,9 @@ import { showFullScreenSpinner } from '../components/modals/AirshipFullScreenSpi import { SPECIAL_CURRENCY_INFO } from '../constants/WalletAndCurrencyConstants' import { lstrings } from '../locales/strings' import { getFioStakingBalances } from './stakeUtils' -import { removeIsoPrefix } from './utils' +// Re-exported, so the GUI's existing importers keep working while one +// module owns the implementation. +export { cleanFiatCurrencyCode } from './fiatCode' /** * Safely get a wallet name, returning a fallback when the name is null. @@ -26,19 +28,6 @@ export function getWalletName(wallet: EdgeCurrencyWallet): string { * Takes any form of fiat currency code and returns a version with and without * the "iso:" prefix */ -export function cleanFiatCurrencyCode(fiatCurrencyCode: string): { - fiatCurrencyCode: string - isoFiatCurrencyCode: string -} { - if (fiatCurrencyCode.startsWith('iso:')) { - return { - fiatCurrencyCode: removeIsoPrefix(fiatCurrencyCode), - isoFiatCurrencyCode: fiatCurrencyCode - } - } else { - return { fiatCurrencyCode, isoFiatCurrencyCode: `iso:${fiatCurrencyCode}` } - } -} export const getAvailableBalance = ( wallet: EdgeCurrencyWallet, diff --git a/src/util/fiatCode.ts b/src/util/fiatCode.ts new file mode 100644 index 00000000000..5c487152342 --- /dev/null +++ b/src/util/fiatCode.ts @@ -0,0 +1,27 @@ +/** + * Fiat code shapes, Node-safe. + * + * `CurrencyWalletHelpers.ts` holds the GUI's copy of this and imports + * Airship, so the CLI cannot reach it — which is why the extraction made a + * second copy. One module both can import instead: the value feeds a + * user-facing string (the fiat code inside an `Exchange:From %1$s` + * subcategory), so two implementations is two places for it to drift. + */ +import { removeIsoPrefix } from './utils' + +/** + * Take any form of fiat currency code and return it both with and without + * the `iso:` prefix. + */ +export function cleanFiatCurrencyCode(fiatCurrencyCode: string): { + fiatCurrencyCode: string + isoFiatCurrencyCode: string +} { + if (fiatCurrencyCode.startsWith('iso:')) { + return { + fiatCurrencyCode: removeIsoPrefix(fiatCurrencyCode), + isoFiatCurrencyCode: fiatCurrencyCode + } + } + return { fiatCurrencyCode, isoFiatCurrencyCode: `iso:${fiatCurrencyCode}` } +} diff --git a/src/util/txDisplay/category.ts b/src/util/txDisplay/category.ts new file mode 100644 index 00000000000..5f2429e1856 --- /dev/null +++ b/src/util/txDisplay/category.ts @@ -0,0 +1,68 @@ +export type Category = 'transfer' | 'exchange' | 'expense' | 'income' + +export interface EdgeCategory { + category: Category + subcategory: string +} + +const prefixes: Record = { + transfer: 'Transfer:', + exchange: 'Exchange:', + expense: 'Expense:', + income: 'Income:' +} + +const tests: Array<[Category, RegExp, number]> = [ + ['transfer', /^Transfer:/i, 9], + ['exchange', /^Exchange:/i, 9], + ['expense', /^Expense:/i, 8], + ['income', /^Income:/i, 7] +] + +/** + * Splits a string into its category and subcategory strings. + * The category must fit our enum type, or we will use a fallback. + * The subcategory can be localized and freely edited. + */ +export function splitCategory( + fullCategory: string = '', + defaultCategory: Category = 'income' +): EdgeCategory { + // Every prefix test requires the colon, so a bare `Expense` still has to + // match the expense category. The original string is what the fallback + // below reads, so this added colon cannot leak into a result. + const probe = + fullCategory.length > 0 && !fullCategory.includes(':') + ? `${fullCategory}:` + : fullCategory + for (const [category, test, n] of tests) { + if (test.test(probe)) { + return { + category, + subcategory: probe.slice(n) + } + } + } + + // We can't guarantee that data on disk is correct, but this should usually + // never happen. The whole stored string becomes the subcategory, because + // none of it is a category this code recognises and all of it is text a + // user — or a dapp through `edgeProvider`, or `--metadata` — put there. + // + // Slicing at the first colon looked tidier and silently destroyed data: + // `Shopping:Food` came back as `{ category: 'income', subcategory: 'Food' }`, + // and `TransactionDetailsScene` round-trips exactly that pair through + // `joinCategory`, so the first time a user opened and saved such a + // transaction the `Shopping` segment was written away. The appended-colon + // case the `probe` above exists for is already handled there, so nothing + // here needs to strip anything. + return { category: defaultCategory, subcategory: fullCategory } +} + +/** + * Combine the category and subcategory into a single string, + * with the correct capitalization. + */ +export function joinCategory(split: EdgeCategory): string { + return prefixes[split.category] + split.subcategory +} diff --git a/src/util/txDisplay/displayInfo.ts b/src/util/txDisplay/displayInfo.ts new file mode 100644 index 00000000000..5dae691b4ff --- /dev/null +++ b/src/util/txDisplay/displayInfo.ts @@ -0,0 +1,470 @@ +/** + * What a transaction should be called. + * + * A pure function of the transaction, the wallet and the account: the + * category, the payee, the direction, and the label for each action type. + * Extracted from `CategoriesActions.ts` so the GUI and a Node caller derive + * the same answer rather than approximately the same answer. + */ +import { eq } from 'biggystring' +import type { + EdgeAccount, + EdgeAssetAction, + EdgeAssetAmount, + EdgeCurrencyWallet, + EdgeMetadata, + EdgeTransaction, + EdgeTxAction +} from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import { lstrings } from '../../locales/strings' +import { cleanFiatCurrencyCode } from '../fiatCode' +import { type EdgeCategory, joinCategory } from './category' +import { getCurrencyCodeWithAccount } from './currencyCodes' +import { txActionLabel } from './txActionLabels' + +/** + * What `getTxActionDisplayInfo` works out about one transaction. + * + * `userData` is what the user wrote, `savedData` what the action implies, and + * `mergedData` the two combined with the user's values winning. + */ +export interface ActionDisplayInfo { + direction: 'send' | 'receive' + iconPluginId?: string + userData: EdgeMetadata + savedData: EdgeMetadata + mergedData: EdgeMetadata + action?: EdgeTxAction + assetAction?: EdgeAssetAction +} + +/** + * Overlay GUI-computed display name/category/notes onto `tx.metadata` for + * API responses. Does not persist. Keeps existing exchangeAmount and other + * fields that `getTxActionDisplayInfo` does not own. + */ +export const fillTxMetadataForDisplay = ( + tx: EdgeTransaction, + mergedData: EdgeMetadata +): EdgeTransaction => ({ + ...tx, + metadata: { + ...tx.metadata, + name: mergedData.name, + category: mergedData.category, + notes: mergedData.notes + } +}) + +/** + * Given a transaction's `EdgeTxAction`, derive the values that pre-fill the + * Category and Notes tiles — unless the user has already edited them, in which + * case `mergedData` keeps what they wrote. + */ +export const getTxActionDisplayInfo = ( + tx: EdgeTransaction, + account: EdgeAccount, + wallet: EdgeCurrencyWallet +): ActionDisplayInfo => { + const { + assetAction, + chainAction, + chainAssetAction, + metadata, + savedAction, + swapData, + tokenId + } = tx + const { currencyConfig, currencyInfo } = wallet + + const displayName = + tokenId == null + ? currencyInfo.assetDisplayName + : currencyConfig.allTokens[tokenId]?.displayName ?? '' + + const action = savedAction ?? chainAction + const assetAct = assetAction ?? chainAssetAction + + const getCurrencyCodes = (assets: EdgeAssetAmount[]): string[] => + assets + .map(asset => + getCurrencyCodeWithAccount(account, asset.pluginId, asset.tokenId) + ) + .filter((currencyCode): currencyCode is string => currencyCode != null) + + const isSentTransaction = + tx.nativeAmount.startsWith('-') || (eq(tx.nativeAmount, '0') && tx.isSend) + + let payeeText: string | undefined + let edgeCategory: EdgeCategory + let direction: 'send' | 'receive' + let notes: string | undefined + let iconPluginId: string | undefined + + // Default text for send or receive + if (isSentTransaction) { + payeeText = sprintf(lstrings.transaction_sent_1s, displayName) + direction = 'send' + edgeCategory = { + category: 'expense', + subcategory: '' + } + } else { + payeeText = sprintf(lstrings.transaction_received_1s, displayName) + direction = 'receive' + edgeCategory = { + category: 'income', + subcategory: '' + } + } + + // Override with swapData + if (swapData != null) { + const { payoutCurrencyCode } = swapData + payeeText = sprintf( + lstrings.transaction_details_swap_to_subcat_1s, + payoutCurrencyCode + ) + } + + if (action != null && assetAct != null) { + const { actionType } = action + const { assetActionType } = assetAct + payeeText = txActionLabel(assetActionType) + + let unsupported = false + + switch (actionType) { + case 'swap': { + iconPluginId = action.swapInfo.pluginId + switch (assetActionType) { + case 'transfer': { + const txSrc = action.payoutWalletId !== wallet.id + const toFromStr = txSrc + ? lstrings.transaction_details_swap_to_subcat_1s + : lstrings.transaction_details_swap_from_subcat_1s + const walletName = + account.currencyWallets[action.payoutWalletId]?.name ?? + displayName + edgeCategory = { + category: 'transfer', + subcategory: sprintf(toFromStr, walletName) + } + break + } + case 'transferNetworkFee': + case 'swapNetworkFee': { + edgeCategory = { + category: 'expense', + subcategory: lstrings.wc_smartcontract_network_fee + } + break + } + case 'swap': + case 'swapOrderFill': { + // Determine if the swap destination was to a different asset or if the + // swap source was from a different asset. + const txSrcSameAsset = + action.fromAsset.tokenId === tokenId && + action.fromAsset.pluginId === wallet.currencyInfo.pluginId + const toFromStr = txSrcSameAsset + ? lstrings.transaction_details_swap_to_subcat_1s + : lstrings.transaction_details_swap_from_subcat_1s + const otherAsset = txSrcSameAsset + ? action.toAsset + : action.fromAsset + + edgeCategory = { + category: 'exchange', + subcategory: sprintf( + toFromStr, + getCurrencyCodeWithAccount( + account, + otherAsset.pluginId, + otherAsset.tokenId + ) + ) + } + direction = txSrcSameAsset ? 'send' : 'receive' + break + } + + case 'swapOrderPost': { + edgeCategory = { + category: 'expense', + subcategory: sprintf(lstrings.transaction_details_swap_order_post) + } + direction = 'send' + break + } + case 'swapOrderCancel': { + edgeCategory = { + category: 'expense', + subcategory: sprintf( + lstrings.transaction_details_swap_order_cancel + ) + } + direction = 'send' + break + } + default: + unsupported = true + } + break + } + case 'stake': { + iconPluginId = action.pluginId + switch (assetActionType) { + case 'stake': { + let subcategory + if (action.stakeAssets.length === 1) + subcategory = sprintf( + lstrings.transaction_details_stake_subcat_1s, + ...getCurrencyCodes(action.stakeAssets) + ) + else if (action.stakeAssets.length === 2) + subcategory = sprintf( + lstrings.transaction_details_stake_subcat_2s, + ...getCurrencyCodes(action.stakeAssets) + ) + else { + console.warn( + `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` + ) + break + } + edgeCategory = { category: 'transfer', subcategory } + direction = 'send' + break + } + case 'stakeOrder': { + if (action.stakeAssets.length === 1) + notes = sprintf( + lstrings.transaction_details_unstake_order_notes_1s, + ...getCurrencyCodes(action.stakeAssets) + ) + else if (action.stakeAssets.length === 2) + notes = sprintf( + lstrings.transaction_details_unstake_order_notes_2s, + ...getCurrencyCodes(action.stakeAssets) + ) + else { + console.error( + `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` + ) + break + } + + edgeCategory = { + category: 'expense', + subcategory: lstrings.transaction_details_stake_order_subcat + } + direction = 'send' + break + } + case 'claim': { + let subcategory + if (action.stakeAssets.length === 1) + subcategory = sprintf( + lstrings.transaction_details_unstake_subcat_1s, + ...getCurrencyCodes(action.stakeAssets) + ) + else if (action.stakeAssets.length === 2) + subcategory = sprintf( + lstrings.transaction_details_unstake_subcat_2s, + ...getCurrencyCodes(action.stakeAssets) + ) + else { + console.error( + `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` + ) + break + } + edgeCategory = { category: 'transfer', subcategory } + if ( + action.stakeAssets.every( + asset => asset.pluginId === currencyInfo.pluginId + ) + ) { + direction = 'receive' + } else { + direction = 'send' + } + break + } + case 'unstake': { + let subcategory + if (action.stakeAssets.length === 1) + subcategory = sprintf( + lstrings.transaction_details_unstake_subcat_1s, + ...getCurrencyCodes(action.stakeAssets) + ) + else if (action.stakeAssets.length === 2) + subcategory = sprintf( + lstrings.transaction_details_unstake_subcat_2s, + ...getCurrencyCodes(action.stakeAssets) + ) + else { + console.error( + `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` + ) + break + } + edgeCategory = { category: 'transfer', subcategory } + direction = 'receive' + break + } + case 'claimOrder': + case 'unstakeOrder': { + if (action.stakeAssets.length === 1) + notes = sprintf( + lstrings.transaction_details_unstake_order_notes_1s, + ...getCurrencyCodes(action.stakeAssets) + ) + else if (action.stakeAssets.length === 2) + notes = sprintf( + lstrings.transaction_details_unstake_order_notes_2s, + ...getCurrencyCodes(action.stakeAssets) + ) + else { + console.error( + `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` + ) + break + } + + edgeCategory = { + category: 'expense', + subcategory: lstrings.transaction_details_unstake_order + } + direction = 'send' + break + } + case 'unstakeNetworkFee': + case 'stakeNetworkFee': { + edgeCategory = { + category: 'expense', + subcategory: lstrings.wc_smartcontract_network_fee + } + break + } + + default: + unsupported = true + } + break + } + case 'fiat': { + iconPluginId = action.fiatPlugin.providerId + switch (assetActionType) { + case 'buy': { + payeeText = sprintf(payeeText, displayName) + const { fiatAsset } = action + const { fiatCurrencyCode } = cleanFiatCurrencyCode( + fiatAsset.fiatCurrencyCode + ) + edgeCategory = { + category: 'exchange', + subcategory: sprintf( + lstrings.transaction_details_swap_from_subcat_1s, + fiatCurrencyCode + ) + } + direction = 'receive' + break + } + case 'sell': { + payeeText = sprintf(payeeText, displayName) + const { fiatAsset } = action + const { fiatCurrencyCode } = cleanFiatCurrencyCode( + fiatAsset.fiatCurrencyCode + ) + edgeCategory = { + category: 'exchange', + subcategory: sprintf( + lstrings.transaction_details_swap_to_subcat_1s, + fiatCurrencyCode + ) + } + direction = 'send' + break + } + case 'sellNetworkFee': { + edgeCategory = { + category: 'expense', + subcategory: lstrings.wc_smartcontract_network_fee + } + direction = 'send' + break + } + default: + unsupported = true + } + break + } + case 'tokenApproval': { + switch (assetActionType) { + case 'tokenApproval': { + edgeCategory = { + category: 'expense', + subcategory: lstrings.wc_smartcontract_network_fee + } + break + } + default: + unsupported = true + } + break + } + case 'giftCard': { + iconPluginId = action.provider.providerId + payeeText = lstrings.gift_card_recipient_name + edgeCategory = { + category: 'expense', + subcategory: action.card.name + } + direction = 'send' + break + } + default: + unsupported = true + } + + if (unsupported) + console.error( + `Unsupported EdgeTxAction assetAction:assetActionType '${assetActionType}'` + ) + } + const savedData: EdgeMetadata = { + name: payeeText, + category: joinCategory(edgeCategory), + notes + } + + const mergedData: EdgeMetadata = { + name: + metadata?.name != null && metadata.name.length > 0 + ? metadata.name + : savedData.name, + category: + metadata?.category != null && metadata.category.length > 0 + ? metadata.category + : savedData.category, + notes: + metadata?.notes != null && metadata.notes.length > 0 + ? metadata.notes + : savedData.notes + } + + return { + action, + assetAction, + direction, + iconPluginId, + savedData, + userData: metadata ?? {}, + mergedData + } +} diff --git a/src/util/txDisplay/index.ts b/src/util/txDisplay/index.ts new file mode 100644 index 00000000000..48e7ef3fd4e --- /dev/null +++ b/src/util/txDisplay/index.ts @@ -0,0 +1,6 @@ +export type { Category, EdgeCategory } from './category' +export { joinCategory, splitCategory } from './category' +export { getCurrencyCodeWithAccount } from './currencyCodes' +export type { ActionDisplayInfo } from './displayInfo' +export { fillTxMetadataForDisplay, getTxActionDisplayInfo } from './displayInfo' +export { txActionLabel } from './txActionLabels' diff --git a/src/util/txDisplay/txActionLabels.ts b/src/util/txDisplay/txActionLabels.ts new file mode 100644 index 00000000000..81160737950 --- /dev/null +++ b/src/util/txDisplay/txActionLabels.ts @@ -0,0 +1,39 @@ +import type { EdgeAssetActionType } from 'edge-core-js' + +import { lstrings } from '../../locales/strings' + +/** + * The label for one `assetActionType`, read at call time. + * + * A module-scope `Record` froze all twenty labels to whatever `lstrings` held + * when *this* module first evaluated. `applyLocale` mutates `lstrings` in + * place, so the values were correct only if the locale boot had already run — + * an invisible ordering dependency on a module nothing here imports. Reading + * them lazily removes it: there is no order in which this can be wrong. + */ +export function txActionLabel(actionType: EdgeAssetActionType): string { + return labelMap()[actionType] +} + +const labelMap = (): Record => ({ + buy: lstrings.transaction_details_bought_1s, + claim: lstrings.transaction_details_claim, + claimOrder: lstrings.transaction_details_claim_order, + giftCard: lstrings.transaction_details_gift_card, + sell: lstrings.transaction_details_sold_1s, + sellNetworkFee: lstrings.fiat_plugin_sell_network_fee, + swap: lstrings.transaction_details_swap, + swapNetworkFee: lstrings.transaction_details_swap_network_fee, + swapOrderPost: lstrings.transaction_details_swap_order_post, + swapOrderFill: lstrings.transaction_details_swap_order_fill, + swapOrderCancel: lstrings.transaction_details_swap_order_cancel, + stake: lstrings.transaction_details_stake, + stakeNetworkFee: lstrings.transaction_details_stake_network_fee, + stakeOrder: lstrings.transaction_details_stake_order, + tokenApproval: lstrings.transaction_details_token_approval, + transfer: lstrings.transaction_details_transfer_funds, + transferNetworkFee: lstrings.transaction_details_transfer_network_fee, + unstake: lstrings.transaction_details_unstake, + unstakeNetworkFee: lstrings.transaction_details_unstake_network_fee, + unstakeOrder: lstrings.transaction_details_unstake_order +}) From 5d94d84bd78d8fe962cd2416d6e03f352c75e612 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 07/24] Extract Node-safe denominations and settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small pieces of GUI state that any caller reading transactions needs, and none of which had a reason to be Redux-only. `exchangeDenom` picks the denomination a currency or token reports amounts in. `DenominationSelectors` keeps its selector shape and calls it, so the two cannot disagree about what a multiplier is. `spamThreshold` decides which incoming transactions are dust worth hiding. The GUI applies it to every list; a caller that reads the same wallet and does not apply it sees a different set of transactions, which is the sort of difference that looks like a bug in whichever one you did not write. `localAccountSettings` reads the device-local settings file that holds the spam filter toggle, and `LocalSettingsActions` reads through it rather than duplicating the format. The threshold needs a rate, and asks for it at the current hour rather than the current millisecond, so repeated listings share one cache entry instead of missing on every call. It is a different source from the GUI's, which reads live rates from Redux, and a lookup that fails yields no filtering — stated in the module, because the two can legitimately disagree. `syncedSettingsFile` holds the one definition of the synced `Settings.json` that Node-safe code reads. The GUI's own cleaner sits behind an Airship import and cannot be loaded here, so this is a two-field view of the same file with the same defaults, and a test asserts those defaults still match the GUI's. --- .../util/localAccountSettings.test.ts | 83 ++++++++ src/__tests__/util/spamThreshold.test.ts | 197 ++++++++++++++++++ src/__tests__/util/syncedSettingsFile.test.ts | 45 ++++ src/actions/LocalSettingsActions.ts | 39 ++-- src/configKeysMerge.ts | 15 +- src/selectors/DenominationSelectors.ts | 24 +-- src/util/exchangeDenom.ts | 36 ++++ src/util/fake/fakeDisklet.ts | 90 ++++++++ src/util/localAccountSettings.ts | 96 +++++++++ src/util/predicates.ts | 69 ++++++ src/util/spamThreshold.ts | 91 ++++++++ src/util/syncedSettingsFile.ts | 67 ++++++ 12 files changed, 805 insertions(+), 47 deletions(-) create mode 100644 src/__tests__/util/localAccountSettings.test.ts create mode 100644 src/__tests__/util/spamThreshold.test.ts create mode 100644 src/__tests__/util/syncedSettingsFile.test.ts create mode 100644 src/util/exchangeDenom.ts create mode 100644 src/util/fake/fakeDisklet.ts create mode 100644 src/util/localAccountSettings.ts create mode 100644 src/util/predicates.ts create mode 100644 src/util/spamThreshold.ts create mode 100644 src/util/syncedSettingsFile.ts diff --git a/src/__tests__/util/localAccountSettings.test.ts b/src/__tests__/util/localAccountSettings.test.ts new file mode 100644 index 00000000000..18db54e59f1 --- /dev/null +++ b/src/__tests__/util/localAccountSettings.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, jest } from '@jest/globals' + +import { makeFakeDiskletAccount } from '../../util/fake/fakeDisklet' +import { + readLocalAccountSettingsFromDisk, + readLocalAccountSettingsOrDefaults +} from '../../util/localAccountSettings' + +/** + * A file that is there and cannot be read. + * + * `account.localDisklet` is core's `encryptDisklet`, whose `getText` runs + * `JSON.parse` then `asEdgeBox` then `decryptText` — so an interrupted write + * surfaces as a parse or cleaner error, which is not any of the three + * spellings `isMissingFile` knows. + */ +const corrupt = (): Error => new SyntaxError('Unexpected end of JSON input') + +describe('readLocalAccountSettingsFromDisk', () => { + it('yields defaults for an absent file', async () => { + const settings = await readLocalAccountSettingsFromDisk( + makeFakeDiskletAccount({}) + ) + expect(settings.spamFilterOn).toBe(true) + }) + + it('yields defaults for a file this version cannot read', async () => { + const settings = await readLocalAccountSettingsFromDisk( + makeFakeDiskletAccount({ local: '{"spamFilterOn":"not a boolean"}' }) + ) + expect(settings.spamFilterOn).toBe(true) + }) + + it('throws when a file that is there cannot be read', async () => { + // Deliberate, and the reason the lenient wrapper exists: a + // read-modify-write caller answering this with defaults and writing them + // back is how `spendingLimits` is destroyed. + await expect( + readLocalAccountSettingsFromDisk( + makeFakeDiskletAccount({ localError: corrupt() }) + ) + ).rejects.toThrow(/Unexpected end of JSON/) + }) +}) + +describe('readLocalAccountSettingsOrDefaults', () => { + it('trusts a read that succeeded', async () => { + const account = makeFakeDiskletAccount({ + local: '{"spamFilterOn":false}' + }) + const { settings, trusted } = await readLocalAccountSettingsOrDefaults( + account + ) + expect(settings.spamFilterOn).toBe(false) + expect(trusted).toBe(true) + }) + + it('trusts an absent file, which is a real answer', async () => { + const { settings, trusted } = await readLocalAccountSettingsOrDefaults( + makeFakeDiskletAccount({}) + ) + expect(settings.spamFilterOn).toBe(true) + expect(trusted).toBe(true) + }) + + it('answers defaults untrusted when the file cannot be read', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { settings, trusted } = await readLocalAccountSettingsOrDefaults( + makeFakeDiskletAccount({ localError: corrupt() }) + ) + // Untrusted, so the GUI's cached reader leaves `readSettingsFromDisk` + // false and a later write cannot persist these over the real file. + expect(settings.spamFilterOn).toBe(true) + expect(trusted).toBe(false) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('using defaults') + ) + } finally { + warn.mockRestore() + } + }) +}) diff --git a/src/__tests__/util/spamThreshold.test.ts b/src/__tests__/util/spamThreshold.test.ts new file mode 100644 index 00000000000..1223b83f116 --- /dev/null +++ b/src/__tests__/util/spamThreshold.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' + +import { getHistoricalCryptoRate } from '../../util/exchangeRates' +import { makeFakeDiskletAccount } from '../../util/fake/fakeDisklet' +import { + readDefaultIsoFiat, + resolveListSpamThreshold +} from '../../util/spamThreshold' + +jest.mock('../../util/exchangeRates', () => ({ + getHistoricalCryptoRate: jest.fn() +})) + +const rateMock = getHistoricalCryptoRate as unknown as jest.Mock< + ( + pluginId: string, + tokenId: unknown, + isoFiat: string, + date: string + ) => Promise +> + +/** An account whose two disklets return whatever text the test supplies. */ +const makeAccount = makeFakeDiskletAccount + +const wallet = { + currencyInfo: { + pluginId: 'bitcoin', + denominations: [{ name: 'BTC', multiplier: '100000000', symbol: '₿' }] + }, + currencyConfig: { + currencyInfo: { + denominations: [{ name: 'BTC', multiplier: '100000000', symbol: '₿' }] + }, + allTokens: {} + } +} as unknown as EdgeCurrencyWallet + +describe('readDefaultIsoFiat', () => { + it('defaults to iso:USD when Settings.json is missing', async () => { + expect(await readDefaultIsoFiat(makeAccount({}))).toBe('iso:USD') + }) + + it('defaults to iso:USD when Settings.json is not JSON', async () => { + const account = makeAccount({ synced: '{not json' }) + expect(await readDefaultIsoFiat(account)).toBe('iso:USD') + }) + + it('defaults to iso:USD when defaultIsoFiat is empty', async () => { + const account = makeAccount({ synced: '{"defaultIsoFiat":""}' }) + expect(await readDefaultIsoFiat(account)).toBe('iso:USD') + }) + + it('returns the stored fiat', async () => { + const account = makeAccount({ synced: '{"defaultIsoFiat":"iso:EUR"}' }) + expect(await readDefaultIsoFiat(account)).toBe('iso:EUR') + }) +}) + +describe('resolveListSpamThreshold', () => { + beforeEach(() => { + rateMock.mockReset() + rateMock.mockResolvedValue(50000) + }) + + it('treats an empty override as "show everything"', async () => { + const threshold = await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null, + queryOverride: '' + }) + expect(threshold).toBe('0') + expect(rateMock).not.toHaveBeenCalled() + }) + + it('passes an explicit override straight through', async () => { + const threshold = await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null, + queryOverride: '500' + }) + expect(threshold).toBe('500') + }) + + it('returns undefined when the spam filter is off', async () => { + const account = makeAccount({ local: '{"spamFilterOn":false}' }) + const threshold = await resolveListSpamThreshold({ + account, + wallet, + tokenId: null + }) + expect(threshold).toBeUndefined() + expect(rateMock).not.toHaveBeenCalled() + }) + + it('still answers when Settings.json cannot be read', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + // Present and unreadable, which `isMissingFile` cannot match. This used + // to propagate out of every `get-transactions` on the default path. + const account = makeAccount({ + localError: new SyntaxError('Unexpected end of JSON input') + }) + const threshold = await resolveListSpamThreshold({ + account, + wallet, + tokenId: null + }) + expect(threshold).not.toBeUndefined() + } finally { + warn.mockRestore() + } + }) + + it('filters by default when no local settings exist', async () => { + const threshold = await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null + }) + expect(threshold).not.toBeUndefined() + expect(rateMock).toHaveBeenCalledTimes(1) + }) + + it('quantises the rate lookup to the hour, so repeat calls hit the cache', async () => { + await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null + }) + + const date = rateMock.mock.calls[0][3] + expect(date).toMatch(/T\d{2}:00:00\.000Z$/) + expect(new Date(date).getTime() % (60 * 60 * 1000)).toBe(0) + }) + + it('does not filter when the rate lookup fails', async () => { + rateMock.mockRejectedValue(new Error('rates server down')) + const threshold = await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null + }) + expect(threshold).toBe('0') + }) + + it('does not filter when the rate is not finite', async () => { + rateMock.mockResolvedValue(Number.NaN) + const threshold = await resolveListSpamThreshold({ + account: makeAccount({}), + wallet, + tokenId: null + }) + expect(threshold).toBe('0') + }) + + it('uses the account default fiat for the rate', async () => { + const account = makeAccount({ synced: '{"defaultIsoFiat":"iso:EUR"}' }) + await resolveListSpamThreshold({ account, wallet, tokenId: null }) + expect(rateMock.mock.calls[0][2]).toBe('iso:EUR') + }) + + it('uses the caller-supplied fiat instead of reading Settings.json again', async () => { + let syncedReads = 0 + const account = { + disklet: { + getText: async () => { + syncedReads++ + return '{"defaultIsoFiat":"iso:EUR"}' + } + }, + localDisklet: { getText: async () => '{"spamFilterOn":true}' } + } as unknown as EdgeAccount + rateMock.mockResolvedValue(30000) + + await resolveListSpamThreshold({ + account, + wallet, + tokenId: null, + isoFiat: 'iso:USD' + }) + + // The handler has already resolved it, and `readSyncedSettings` — which + // is what reads the *synced* Settings.json this counter is counting — + // deliberately has no process-wide cache to absorb a repeat read. + expect(syncedReads).toBe(0) + expect(rateMock).toHaveBeenCalledWith( + 'bitcoin', + null, + 'iso:USD', + expect.any(String) + ) + }) +}) diff --git a/src/__tests__/util/syncedSettingsFile.test.ts b/src/__tests__/util/syncedSettingsFile.test.ts new file mode 100644 index 00000000000..7c92fd93bb8 --- /dev/null +++ b/src/__tests__/util/syncedSettingsFile.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals' + +import { asSyncedAccountSettings } from '../../actions/SettingsActions' +import { + asSyncedSettingsSubset, + SYNCED_SETTINGS_FILENAME +} from '../../util/syncedSettingsFile' + +describe('asSyncedSettingsSubset', () => { + it('agrees with the GUI cleaner on the fields it shares', () => { + // The GUI's cleaner cannot be imported by Node-safe code (it pulls in + // Airship), so this pins the subset's defaults to it instead. + const guiDefaults = asSyncedAccountSettings({}) + const subsetDefaults = asSyncedSettingsSubset({}) + + expect(subsetDefaults.autoLogoutTimeInSeconds).toBe( + guiDefaults.autoLogoutTimeInSeconds + ) + expect(subsetDefaults.defaultIsoFiat).toBe(guiDefaults.defaultIsoFiat) + }) + + it('reads the same file the GUI writes', () => { + expect(SYNCED_SETTINGS_FILENAME).toBe('Settings.json') + }) + + it('keeps unknown fields rather than stripping the GUI’s settings', () => { + const cleaned = asSyncedSettingsSubset({ + autoLogoutTimeInSeconds: 60, + walletsSort: 'name' + }) + expect(cleaned.autoLogoutTimeInSeconds).toBe(60) + expect((cleaned as unknown as { walletsSort: string }).walletsSort).toBe( + 'name' + ) + }) + + it('falls back to the defaults for a malformed value', () => { + const cleaned = asSyncedSettingsSubset({ + autoLogoutTimeInSeconds: 'soon', + defaultIsoFiat: 42 + }) + expect(cleaned.autoLogoutTimeInSeconds).toBe(3600) + expect(cleaned.defaultIsoFiat).toBe('iso:USD') + }) +}) diff --git a/src/actions/LocalSettingsActions.ts b/src/actions/LocalSettingsActions.ts index 0e18aa591c2..7d28d5ec2e5 100644 --- a/src/actions/LocalSettingsActions.ts +++ b/src/actions/LocalSettingsActions.ts @@ -15,9 +15,14 @@ import { type PasswordReminder, type SpendingLimits } from '../types/types' +import { + LOCAL_SETTINGS_FILENAME, + readLocalAccountSettingsOrDefaults, + writeLocalAccountSettingsToDisk +} from '../util/localAccountSettings' import { logActivity } from '../util/logger' -export const LOCAL_SETTINGS_FILENAME = 'Settings.json' +export { LOCAL_SETTINGS_FILENAME } // Long enough to read the instructions in the balance-hidden toast: const TOAST_HIDE_MS = 5000 @@ -316,21 +321,18 @@ export const readLocalAccountSettings = async ( return localAccountSettings } - try { - const text = await account.localDisklet.getText(LOCAL_SETTINGS_FILENAME) - const json = JSON.parse(text) - const settings = asLocalAccountSettings(json) - emitAccountSettings(settings) - readSettingsFromDisk = true - return settings - } catch (error: unknown) { - // If Settings.json doesn't exist yet, return defaults without writing. - // Defaults can be derived from cleaners. Only write when values change. - const defaults = asLocalAccountSettings({}) - emitAccountSettings(defaults) - readSettingsFromDisk = true - return defaults - } + // Lenient: this is the GUI's read-only cached reader, reached from + // `initializeAccount`, and before this branch it could not fail. A + // `Settings.json` that is present but unreadable must not stop the login. + // `readSettingsFromDisk` stays false in that case, so a later + // `writeLocalAccountSettings` cannot persist these defaults over the real + // file — the loss the strict reader exists to prevent. + const { settings, trusted } = await readLocalAccountSettingsOrDefaults( + account + ) + emitAccountSettings(settings) + readSettingsFromDisk = trusted + return settings } export const writeLocalAccountSettings = async ( @@ -339,9 +341,6 @@ export const writeLocalAccountSettings = async ( ): Promise => { // Refresh cache, notify callers emitAccountSettings(settings) - - const text = JSON.stringify(settings) - await account.localDisklet.setText(LOCAL_SETTINGS_FILENAME, text) - + await writeLocalAccountSettingsToDisk(account, settings) return settings } diff --git a/src/configKeysMerge.ts b/src/configKeysMerge.ts index ea5c69f656d..f3129770a2e 100644 --- a/src/configKeysMerge.ts +++ b/src/configKeysMerge.ts @@ -9,15 +9,20 @@ import { type GlobalKeys, type RuntimeKeys } from './configKeysSchema' +import { isPlainObject } from './util/predicates' /** Open string-keyed object; fails closed on non-objects (unlike a soft coerce). */ const asUnknownMap = asObject(asUnknown) -export function isPlainObject( - value: unknown -): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} +/** + * Re-exported, not re-declared. + * + * This module exported a byte-equivalent second copy, and + * `src/util/keysStore.ts` imports it from here — so the repo had two + * exported `isPlainObject` functions and a hand-written third test inside + * `requireBodyObject`, which is what `predicates.ts` was created to end. + */ +export { isPlainObject } /** Maps inside a keys payload that must stay objects to merge safely. */ const KEYS_PAYLOAD_MAP_FIELDS = [ diff --git a/src/selectors/DenominationSelectors.ts b/src/selectors/DenominationSelectors.ts index 53fa7dd6ab0..1003c4ce71a 100644 --- a/src/selectors/DenominationSelectors.ts +++ b/src/selectors/DenominationSelectors.ts @@ -5,12 +5,9 @@ import type { } from 'edge-core-js' import type { RootState } from '../types/reduxTypes' +import { emptyEdgeDenomination, getExchangeDenom } from '../util/exchangeDenom' -export const emptyEdgeDenomination: EdgeDenomination = Object.freeze({ - name: '', - multiplier: '1', - symbol: '' -}) +export { emptyEdgeDenomination, getExchangeDenom } from '../util/exchangeDenom' export const selectDisplayDenom = ( state: RootState, @@ -33,20 +30,3 @@ export const selectDisplayDenom = ( } return exchangeDenomination } - -/** - * Looks up the denomination for a tokenId. - * Pass either `account.currencyConfig[pluginId]` or `wallet.currencyConfig`, - * whichever you have. - */ -export function getExchangeDenom( - currencyConfig: EdgeCurrencyConfig, - tokenId: EdgeTokenId -): EdgeDenomination { - if (tokenId == null) return currencyConfig.currencyInfo.denominations[0] - - const token = currencyConfig.allTokens[tokenId] - if (token != null) return token.denominations[0] - - return emptyEdgeDenomination -} diff --git a/src/util/exchangeDenom.ts b/src/util/exchangeDenom.ts new file mode 100644 index 00000000000..3f87f49a13e --- /dev/null +++ b/src/util/exchangeDenom.ts @@ -0,0 +1,36 @@ +import type { + EdgeCurrencyConfig, + EdgeDenomination, + EdgeTokenId +} from 'edge-core-js' + +import { hasOwn } from './predicates' + +export const emptyEdgeDenomination: EdgeDenomination = Object.freeze({ + name: '', + multiplier: '1', + symbol: '' +}) + +/** + * Looks up the denomination for a tokenId. + * Pass either `account.currencyConfig[pluginId]` or `wallet.currencyConfig`, + * whichever you have. + */ +export function getExchangeDenom( + currencyConfig: EdgeCurrencyConfig, + tokenId: EdgeTokenId +): EdgeDenomination { + if (tokenId == null) return currencyConfig.currencyInfo.denominations[0] + + // `hasOwn`, because `allTokens` is a plain object: `allTokens['__proto__']` + // is `Object.prototype`, which is not null, so this took the token branch + // and evaluated `Object.prototype.denominations[0]` — a `TypeError`, and + // `spamThreshold.ts` reaches here before any route-level token check runs. + if (hasOwn(currencyConfig.allTokens, tokenId)) { + const token = currencyConfig.allTokens[tokenId] + if (token != null) return token.denominations[0] + } + + return emptyEdgeDenomination +} diff --git a/src/util/fake/fakeDisklet.ts b/src/util/fake/fakeDisklet.ts new file mode 100644 index 00000000000..8a7f9842b72 --- /dev/null +++ b/src/util/fake/fakeDisklet.ts @@ -0,0 +1,90 @@ +/** + * Accounts and wallets whose disklets return whatever a test supplies. + * + * Several suites built the same two stubs by hand — "an account whose + * disklet returns this text", "a bitcoin wallet with one denomination" — + * under the same names, with the `File not found` sentinel repeated because + * that is what `isMissingFile` matches. Eleven ad-hoc + * `as unknown as EdgeAccount` literals across seven files, which is how a + * change to what counts as an absent file ends up fixed in some of them. + */ +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' + +/** + * The error disklet raises for a file that is not there. + * + * Three backends spell it three ways and `isMissingFile` knows all three; + * this is the memory backend's, which is what a stub should imitate. + */ +export function missingFileError(): Error { + return new Error('File not found') +} + +export interface FakeDiskletAccountOpts { + /** Text the *synced* disklet returns, or absent for a missing file. */ + synced?: string + /** Text the *local* disklet returns, or absent for a missing file. */ + local?: string + /** + * An error the *local* disklet raises instead of answering. + * + * For a file that is present and unreadable, which is a different case + * from absent: `account.localDisklet` is core's `encryptDisklet`, so a + * truncated file fails inside `JSON.parse`/`decryptText` and does not + * match `isMissingFile`. + */ + localError?: Error + /** Counts each synced read, for a test measuring repeat reads. */ + onSyncedRead?: () => void + username?: string + rootLoginId?: string +} + +/** An account that is nothing but two disklets. */ +export function makeFakeDiskletAccount( + opts: FakeDiskletAccountOpts = {} +): EdgeAccount { + const read = async (text: string | undefined): Promise => { + if (text == null) throw missingFileError() + return text + } + return { + username: opts.username ?? 'clitester', + rootLoginId: opts.rootLoginId ?? 'root123', + logout: async () => {}, + waitForAllWallets: async () => {}, + disklet: { + getText: async () => { + opts.onSyncedRead?.() + return await read(opts.synced) + } + }, + localDisklet: { + getText: async () => { + if (opts.localError != null) throw opts.localError + return await read(opts.local) + } + } + } as unknown as EdgeAccount +} + +const BTC_DENOM = { name: 'BTC', multiplier: '100000000', symbol: '₿' } + +/** A wallet with one denomination and no tokens. */ +export function makeFakeDenomWallet( + opts: { pluginId?: string; id?: string } = {} +): EdgeCurrencyWallet { + const pluginId = opts.pluginId ?? 'bitcoin' + return { + id: opts.id ?? 'wallet-1', + currencyInfo: { + pluginId, + currencyCode: 'BTC', + denominations: [BTC_DENOM] + }, + currencyConfig: { + currencyInfo: { pluginId, denominations: [BTC_DENOM] }, + allTokens: {} + } + } as unknown as EdgeCurrencyWallet +} diff --git a/src/util/localAccountSettings.ts b/src/util/localAccountSettings.ts new file mode 100644 index 00000000000..5f62db0cb44 --- /dev/null +++ b/src/util/localAccountSettings.ts @@ -0,0 +1,96 @@ +import { asJSON, asMaybe, uncleaner } from 'cleaners' +import type { EdgeAccount } from 'edge-core-js' + +import { + asLocalAccountSettings, + type LocalAccountSettings +} from '../types/types' +import { isMissingFile } from './predicates' + +const uncleanLocalAccountSettings = uncleaner(asLocalAccountSettings) + +export const LOCAL_SETTINGS_FILENAME = 'Settings.json' + +/** + * Read account.localDisklet Settings.json. + * + * An **absent** file, or one whose contents this version cannot read, yields + * the cleaner's defaults (`spamFilterOn: true`). A failure to *read* a file + * that is there does not: it throws. + * + * The distinction is the whole point. `changeLocalSettings` is a + * read-modify-write over this one file, so answering any failure with the + * 13 defaults and then writing them back turned one unreadable file — an + * interrupted write, a bad sync — into the loss of `spendingLimits`, + * `passwordReminder`, `notifState`, `reviewTrigger`, `developerModeOn`, + * `isAccountBalanceVisible` and `tokenWarningsShown`. The user's spending + * limits among them. + * + * No process-wide cache — the GUI wrapper in LocalSettingsActions.ts keeps + * that. + */ +export async function readLocalAccountSettingsFromDisk( + account: EdgeAccount +): Promise { + let text: string + try { + text = await account.localDisklet.getText(LOCAL_SETTINGS_FILENAME) + } catch (error: unknown) { + if (!isMissingFile(error)) throw error + return asLocalAccountSettings({}) + } + // `asJSON`, so one cleaner owns both the parse and the shape. A file this + // version cannot read is as good as absent: the cleaner's defaults are the + // only thing it could become either way. + return ( + asMaybe(asJSON(asLocalAccountSettings))(text) ?? asLocalAccountSettings({}) + ) +} + +/** + * As `readLocalAccountSettingsFromDisk`, for a caller that only reads. + * + * The strict reader exists so a read-modify-write cannot answer an + * unreadable file with the 13 defaults and then write them back over the + * user's `spendingLimits`. That argument is about the *write*, and applying + * it to read-only callers took something that could not fail before this + * branch and made it fatal: `account.localDisklet` is core's + * `encryptDisklet`, so a truncated `Settings.json` — the interrupted write + * the strict reader's own docstring cites — throws a parse or cleaner error + * that `isMissingFile` cannot match. That propagated out of + * `initializeAccount`, so the `LOGIN` action was never dispatched and the + * user sat on the login scene with a toast on every attempt; it also hard + * failed `GET /local-settings` and every `get-transactions`, through + * `resolveListSpamThreshold`. + * + * `trusted` is false when the file was there and could not be read. A caller + * that caches must not then mark itself authoritative, or a later write would + * persist these defaults over the real file — which is the destruction the + * strict reader is for, arriving by the other door. + */ +export async function readLocalAccountSettingsOrDefaults( + account: EdgeAccount +): Promise<{ settings: LocalAccountSettings; trusted: boolean }> { + try { + return { + settings: await readLocalAccountSettingsFromDisk(account), + trusted: true + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.warn( + `Could not read ${LOCAL_SETTINGS_FILENAME}, using defaults: ${message}` + ) + return { settings: asLocalAccountSettings({}), trusted: false } + } +} + +export async function writeLocalAccountSettingsToDisk( + account: EdgeAccount, + settings: LocalAccountSettings +): Promise { + // Through the cleaner's uncleaner, so a shape change is a compile error. + const text = JSON.stringify(uncleanLocalAccountSettings(settings)) + await account.localDisklet.setText(LOCAL_SETTINGS_FILENAME, text) + return settings +} diff --git a/src/util/predicates.ts b/src/util/predicates.ts new file mode 100644 index 00000000000..1c480d0c948 --- /dev/null +++ b/src/util/predicates.ts @@ -0,0 +1,69 @@ +/** + * Small predicates the engine and the GUI share. + * + * Each of these existed in four or five places under two or three names: + * `isObject`, `isPlainObject` and the body-shape test inside + * `requireBodyObject` are the same check, and `isMissingFile` was + * byte-identical in three modules. One copy each, so a change to what counts + * as "an object" or "an absent file" happens once. + * + * In `src/util/` rather than `src/cli/engine/`, because + * `src/util/exportTxInfo.ts` uses it and that module is reached from + * `TransactionsExportScene`: the shipped React Native bundle must not import + * out of the daemon's directory, and it only worked because this file + * happens to have no imports. It still has none, so nothing is dragged in by + * using it. + */ + +/** An object that is not an array — the shape a JSON body or record has. */ +export function isPlainObject( + value: unknown +): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * A key the object really has, not one it inherits. + * + * `map[key] != null` is not that test. Core builds `account.currencyWallets` + * and `currencyConfig.allTokens` as plain object literals, so + * `wallets['__proto__']` is `Object.prototype` — truthy — and + * `allTokens['__proto__'] == null` is false. Every lookup whose key is + * caller input therefore let `__proto__`, `constructor`, `toString`, + * `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and + * `toLocaleString` past the guard that exists to stop them, and the + * `TypeError` that followed surfaced as `500 INTERNAL_ERROR` with no field + * name. + * + * `Object.prototype.hasOwnProperty.call`, not `Object.hasOwn`: this module + * is reached from the React Native bundle as well as the daemon. + */ +export function hasOwn(object: object, key: string | number | symbol): boolean { + return Object.prototype.hasOwnProperty.call(object, key) +} + +/** + * An absent file, as opposed to a failure to read one that is there. + * + * Three spellings, because three backends produce it: `ENOENT` from + * disklet's node backend, `File not found` from its memory backend, and + * `Cannot load ""` from core's encrypted repo disklet. That last one + * was missing, and it is the one the engine actually meets — a wallet that + * has never saved export preferences answered `500 INTERNAL_ERROR` instead + * of starting a fresh map, so `get-transactions --save-export-prefs` could + * not succeed on any wallet. + * + * The distinction matters in the other direction too: answering a real I/O + * failure the way an absent file is answered turns one unreadable file into + * a rewrite of whatever it held. + */ +export function isMissingFile(error: unknown): boolean { + if (error == null || typeof error !== 'object') return false + const code = (error as { code?: unknown }).code + if (code === 'ENOENT') return true + const message = (error as { message?: unknown }).message + return ( + typeof message === 'string' && + /not found|no such file|cannot load/i.test(message) + ) +} diff --git a/src/util/spamThreshold.ts b/src/util/spamThreshold.ts new file mode 100644 index 00000000000..9e759cc34ca --- /dev/null +++ b/src/util/spamThreshold.ts @@ -0,0 +1,91 @@ +import type { EdgeAccount, EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' + +import { getExchangeDenom } from './exchangeDenom' +import { getHistoricalCryptoRate } from './exchangeRates' +import { readLocalAccountSettingsOrDefaults } from './localAccountSettings' +import { + asSyncedSettingsSubset, + readSyncedSettings +} from './syncedSettingsFile' +import { calculateSpamThreshold } from './utils' + +/** + * Synced account Settings.json on account.disklet (not localDisklet). + * defaultIsoFiat defaults to iso:USD, matching asSyncedAccountSettings. + */ +export async function readDefaultIsoFiat( + account: EdgeAccount +): Promise { + const { defaultIsoFiat } = await readSyncedSettings(account) + // The cleaner's fallback covers an absent field; an empty *string* in the + // file is a value it would keep, and no fiat code is the empty string. + return defaultIsoFiat === '' + ? asSyncedSettingsSubset({}).defaultIsoFiat + : defaultIsoFiat +} + +/** An hour is finer than a dust threshold needs and coarse enough to cache. */ +const RATE_BUCKET_MS = 60 * 60 * 1000 + +/** + * Same visibility rule as the GUI transaction list, with a different rate + * source: the GUI reads live rates from Redux, this asks for a historical + * rate at the current hour. An explicit query override wins. Otherwise honor + * spamFilterOn (default true) and calculateSpamThreshold from defaultIsoFiat + * and that rate. A missing or non-finite rate yields `'0'` — no filtering — + * where the GUI's live rate would have filtered. + */ +export async function resolveListSpamThreshold(opts: { + account: EdgeAccount + wallet: EdgeCurrencyWallet + tokenId: EdgeTokenId + /** + * The fiat to price in, resolved by the caller. + * + * Passed in rather than read again: the only caller has already resolved + * it, and reading the account's synced `Settings.json` a second time for + * the identical answer is the whole cost of this function's default path. + */ + isoFiat?: string + queryOverride?: string +}): Promise { + // An empty override means "no threshold", which core spells `'0'`. The + // REST route cannot produce it — `queryToObject` reads `?spamThreshold=` as + // an absent parameter, and `?spamThreshold=0` is the spelling that route + // documents — but a direct caller can, and `''` must not reach core. + if (opts.queryOverride !== undefined) { + return opts.queryOverride === '' ? '0' : opts.queryOverride + } + + // Lenient, and this is the one that matters most: a `Settings.json` that + // cannot be read used to fail *every* `get-transactions`, on the default + // path, with nothing here catching it. + const { settings } = await readLocalAccountSettingsOrDefaults(opts.account) + if (!settings.spamFilterOn) return undefined + + const defaultIsoFiat = + opts.isoFiat ?? (await readDefaultIsoFiat(opts.account)) + const denom = getExchangeDenom(opts.wallet.currencyConfig, opts.tokenId) + let rate = 0 + try { + // Quantised to the hour. `getHistoricalCryptoRate` folds the date string + // into its cache key, so a millisecond-precision timestamp missed the + // cache on every call: each listing waited a full FETCH_FREQUENCY before + // the request even started, and left a permanent entry in the module-level + // rate map. In the GUI this value came from a Redux selector, so neither + // cost existed there. + const bucketedNow = new Date( + Math.floor(Date.now() / RATE_BUCKET_MS) * RATE_BUCKET_MS + ).toISOString() + rate = await getHistoricalCryptoRate( + opts.wallet.currencyInfo.pluginId, + opts.tokenId, + defaultIsoFiat, + bucketedNow + ) + } catch { + rate = 0 + } + if (!Number.isFinite(rate)) rate = 0 + return calculateSpamThreshold(rate, denom) +} diff --git a/src/util/syncedSettingsFile.ts b/src/util/syncedSettingsFile.ts new file mode 100644 index 00000000000..04e99560b08 --- /dev/null +++ b/src/util/syncedSettingsFile.ts @@ -0,0 +1,67 @@ +import { asJSON, asMaybe, asNumber, asObject, asString } from 'cleaners' + +/** The synced account settings file, on `account.disklet`. */ +export const SYNCED_SETTINGS_FILENAME = 'Settings.json' + +/** + * The fields of the synced settings that Node-safe code reads. + * + * The GUI's full `asSyncedAccountSettings` lives in `actions/SettingsActions`, + * which imports Airship, so neither the CLI engine nor the extracted modules + * can reach it. This is a narrow `.withRest` view of the same file with the + * same defaults — `syncedSettingsFile.test.ts` asserts the two agree, so the + * subset cannot drift from the file the GUI writes. + */ +export const asSyncedSettingsSubset = asObject({ + autoLogoutTimeInSeconds: asMaybe(asNumber, 3600), + defaultIsoFiat: asMaybe(asString, 'iso:USD') +}).withRest + +/** + * The account's synced settings, with the cleaner's defaults applied. + * + * One reader. `readAutoLogoutSeconds` and `readDefaultIsoFiat` were the same + * `getText` → `asMaybe(asJSON(…))` → field wrapper with an identical empty + * `catch`, and each restated a default the cleaner already supplies — so + * changing a default here left two call sites stale. + * + * An absent file, or one this version cannot read, yields the defaults — + * which is what every caller wanted from its own `catch`. A failure to read + * a file that *is* there throws, so a caller that must not change its mind + * on a transient failure can tell the two apart; `readSyncedSettingsOrThrow` + * below is that caller's entry point. + */ +export async function readSyncedSettings(account: { + disklet: { getText: (path: string) => Promise } +}): Promise { + try { + return await readSyncedSettingsOrThrow(account) + } catch { + // Unreadable: the defaults are the answer for a caller with nothing + // better to fall back to. + return asSyncedSettingsSubset({}) + } +} + +/** + * The same read, letting an I/O or decryption failure out. + * + * The engine's auto-logout ticker re-reads this file every sweep and has a + * `catch` whose stated job is to keep the last known window. That `catch` + * was dead code, because `readSyncedSettings` swallows the failure itself — + * so one unreadable read silently replaced a live `autoLogoutTimeInSeconds` + * with the cleaner's 3600, and an account that had set `0` to disable + * auto-logout was logged out an hour later, mid-script. + */ +export async function readSyncedSettingsOrThrow(account: { + disklet: { getText: (path: string) => Promise } +}): Promise { + const text = await account.disklet.getText(SYNCED_SETTINGS_FILENAME) + // A file this version cannot parse is as good as absent: the defaults are + // the only thing it could become. Only the read itself is allowed to fail. + return ( + asMaybe(asJSON(asSyncedSettingsSubset))(text) ?? asSyncedSettingsSubset({}) + ) +} + +export type SyncedSettingsSubset = ReturnType From 249c8da7ce1a03e66ff5419118f98a1f891e7a6c Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 08/24] Extract the Node-safe transaction export pipeline `TransactionExportActions.tsx` was a five-hundred-line thunk that did four separable jobs: fill in historical fiat values, render CSV, render QBO, and render the Bitwave format. Only the last step needed React Native, and only for writing the file. `fillTxsFiat` asks the rates server what each transaction was worth on the day it happened, which is the part that makes an export more than a dump of native amounts. `txExport/format` renders the three formats. `exportTxInfo` holds the Bitwave account mapping the exporter needs. The thunk keeps the file-writing and the share sheet, and calls the same renderers. `TransactionsExportScene` follows it. A test now covers `fillTxsFiat` against a partial wallet, which is all it reads. The formats matter here: an export that a person reconciles against their books has to be byte-identical whichever tool produced it, and the only way to be sure of that is for one renderer to produce both. --- .../actions/TransactionExportActions.test.ts | 36 +- src/__tests__/fillTxsFiat.test.ts | 149 +++++ src/__tests__/util/serializeByKey.test.ts | 77 +++ src/actions/CategoriesActions.ts | 501 +---------------- src/actions/TransactionExportActions.tsx | 525 +----------------- .../scenes/TransactionsExportScene.tsx | 62 +-- src/constants/txActionConstants.ts | 26 - src/util/exportTxInfo.ts | 112 ++++ src/util/fillTxsFiat.ts | 86 +++ src/util/serializeByKey.ts | 38 ++ src/util/txExport/format.ts | 490 ++++++++++++++++ src/util/txExport/index.ts | 41 ++ 12 files changed, 1068 insertions(+), 1075 deletions(-) create mode 100644 src/__tests__/fillTxsFiat.test.ts create mode 100644 src/__tests__/util/serializeByKey.test.ts delete mode 100644 src/constants/txActionConstants.ts create mode 100644 src/util/exportTxInfo.ts create mode 100644 src/util/fillTxsFiat.ts create mode 100644 src/util/serializeByKey.ts create mode 100644 src/util/txExport/format.ts create mode 100644 src/util/txExport/index.ts diff --git a/src/__tests__/actions/TransactionExportActions.test.ts b/src/__tests__/actions/TransactionExportActions.test.ts index cb590dcfacf..e10325c10a8 100644 --- a/src/__tests__/actions/TransactionExportActions.test.ts +++ b/src/__tests__/actions/TransactionExportActions.test.ts @@ -6,7 +6,7 @@ import { exportTransactionsToBitwave, exportTransactionsToCSVInner, exportTransactionsToQBO -} from '../../actions/TransactionExportActions' +} from '../../util/txExport' const csvResult = fs.readFileSync('./src/__tests__/exportCsvResult.csv', { encoding: 'utf8' @@ -196,6 +196,40 @@ test('export QBO matches reference data', function () { expect(out).toEqual(qboResult) }) +test('export QBO keeps its declared charset', function () { + // The header says `ENCODING:USASCII` / `CHARSET:1252`, which is what + // accounting importers expect, so the bytes have to match it. A payee or + // memo holds anything a dapp, `--metadata` or the engine's localized prose + // puts there, and those were previously emitted raw under an ASCII + // declaration. The alternative considered — declaring `ENCODING:UNICODE` — + // changed the format on 100% of exports and is not unambiguously UTF-8 in + // OFX 1.x. + const out = exportTransactionsToQBO( + [ + { + ...edgeTxs[0], + metadata: { + name: 'Café Münster — naïve', + notes: 'Überweisung ✓', + category: 'Expense:Food' + } + } + ], + 'iso:USD', + '100', + 1524578071304 + ) + expect(out).toContain('ENCODING:USASCII') + expect(out).toContain('CHARSET:1252') + // Every byte inside the printable-ASCII range, so the declaration is true. + // eslint-disable-next-line no-control-regex + expect(/[^\x09\x0a\x0d\x20-\x7e]/.test(out)).toBe(false) + // And the original characters are recoverable, as numeric references. + expect(out).toContain('Café') + expect(out).toContain('Münster') + expect(out).toContain('—') +}) + test('export Bitwave matches reference data', async function () { const out = await exportTransactionsToBitwave( BITWAVE_ACCOUNT_ID, diff --git a/src/__tests__/fillTxsFiat.test.ts b/src/__tests__/fillTxsFiat.test.ts new file mode 100644 index 00000000000..4660f724f8c --- /dev/null +++ b/src/__tests__/fillTxsFiat.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { EdgeCurrencyWallet, EdgeTransaction } from 'edge-core-js' + +import { fillTxsFiat, toIsoFiatCode } from '../util/fillTxsFiat' + +/** + * Records how many lookups are outstanding at once. + * + * The point of the change this suite covers is that every rate is queued + * *before* anything is awaited, so one `doQuery` cycle drains a whole page + * instead of one cycle per ten transactions — roughly 1.1s against 120s for + * 1,200 transactions, which is past the client's own socket timeout. Without + * measuring concurrency, the old chunked loop and the new one produce + * byte-identical results under a mock, so the shape could regress silently. + */ +const concurrency = { current: 0, peak: 0 } + +jest.mock('../util/exchangeRates', () => ({ + getHistoricalCryptoRate: jest.fn( + async (_pluginId: string, _tokenId: unknown, isoFiat: string) => { + concurrency.current++ + if (concurrency.current > concurrency.peak) { + concurrency.peak = concurrency.current + } + // A microtask boundary, not a timer: `jestSetup` installs fake timers + // globally, so a `setTimeout` here would never fire. Every caller + // queued before the first await has incremented by the time this + // resumes. + await Promise.resolve() + concurrency.current-- + if (isoFiat === 'iso:EUR') return 40000 + return 50000 + } + ) +})) + +function makeWallet(): EdgeCurrencyWallet { + // Incomplete core wallet — fillTxsFiat only reads currencyInfo and + // currencyConfig, so the cast says what the shape really is. + const wallet = { + currencyInfo: { pluginId: 'bitcoin' }, + currencyConfig: { + currencyInfo: { + pluginId: 'bitcoin', + denominations: [{ name: 'BTC', multiplier: '100000000', symbol: '₿' }] + }, + allTokens: {} + } + } + return wallet as unknown as EdgeCurrencyWallet +} + +function makeTx(overrides: Partial = {}): EdgeTransaction { + const tx: EdgeTransaction = { + blockHeight: 1, + currencyCode: 'BTC', + date: 1700000000, + deviceDescription: 'test', + isSend: false, + memos: [], + nativeAmount: '100000000', + networkFee: '0', + networkFees: [], + ourReceiveAddresses: [], + parentNetworkFee: '0', + signedTx: '', + tokenId: null, + txid: 'txid', + walletId: '', + ...overrides + } + return tx +} + +describe('fillTxsFiat', () => { + it('fills missing isoFiat from the historical rate', async () => { + const tx = makeTx({ metadata: { name: 'Keep me' } }) + await fillTxsFiat({ + wallet: makeWallet(), + tokenId: null, + isoFiat: 'iso:USD', + txs: [tx] + }) + expect(tx.metadata?.name).toBe('Keep me') + expect(tx.metadata?.exchangeAmount?.['iso:USD']).toBe(50000) + }) + + it('skips txs that already have a non-zero amount for that fiat', async () => { + const tx = makeTx({ + metadata: { exchangeAmount: { 'iso:USD': 12.5 } } + }) + await fillTxsFiat({ + wallet: makeWallet(), + tokenId: null, + isoFiat: 'iso:USD', + txs: [tx] + }) + expect(tx.metadata?.exchangeAmount?.['iso:USD']).toBe(12.5) + }) + + it('fills an override fiat without dropping other stored amounts', async () => { + const tx = makeTx({ + metadata: { exchangeAmount: { 'iso:USD': 12.5 } } + }) + await fillTxsFiat({ + wallet: makeWallet(), + tokenId: null, + isoFiat: 'iso:EUR', + txs: [tx] + }) + expect(tx.metadata?.exchangeAmount?.['iso:USD']).toBe(12.5) + expect(tx.metadata?.exchangeAmount?.['iso:EUR']).toBe(40000) + }) +}) + +describe('toIsoFiatCode', () => { + it('accepts a 3-letter code, optional iso: prefix, and any case', () => { + expect(toIsoFiatCode('USD')).toBe('iso:USD') + expect(toIsoFiatCode('eur')).toBe('iso:EUR') + expect(toIsoFiatCode('iso:GBP')).toBe('iso:GBP') + expect(toIsoFiatCode(' cad ')).toBe('iso:CAD') + }) + + it('rejects non-fiat codes', () => { + expect(toIsoFiatCode('')).toBeUndefined() + expect(toIsoFiatCode('US')).toBeUndefined() + expect(toIsoFiatCode('USDT')).toBeUndefined() + expect(toIsoFiatCode('123')).toBeUndefined() + }) +}) + +describe('fillTxsFiat concurrency', () => { + it('queues every rate before awaiting any of them', async () => { + concurrency.current = 0 + concurrency.peak = 0 + const txs = Array.from({ length: 25 }, (_unused, i) => + makeTx({ txid: `tx${i}`, date: 1700000000 + i * 86_400 }) + ) + await fillTxsFiat({ + wallet: makeWallet(), + tokenId: null, + isoFiat: 'iso:USD', + txs + }) + // 25, not 10: the chunked loop awaited ten at a time, and each chunk + // paid its own debounce. + expect(concurrency.peak).toBe(25) + }) +}) diff --git a/src/__tests__/util/serializeByKey.test.ts b/src/__tests__/util/serializeByKey.test.ts new file mode 100644 index 00000000000..bbed71aef13 --- /dev/null +++ b/src/__tests__/util/serializeByKey.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals' + +import { pendingKeyCount, serializeByKey } from '../../util/serializeByKey' + +// `jestSetup.js` installs fake timers for every suite. These cases are about +// real interleaving, so they need the real clock. +beforeAll(() => { + jest.useRealTimers() +}) +afterAll(() => { + jest.useFakeTimers() +}) + +/** A read-modify-write over one shared value, with an await in the middle. */ +function makeStore(): { + bump: () => Promise + value: () => number +} { + let value = 0 + return { + value: () => value, + bump: async () => { + const read = value + await new Promise(resolve => setTimeout(resolve, 5)) + value = read + 1 + } + } +} + +describe('serializeByKey', () => { + it('a read-modify-write loses updates without it', async () => { + const store = makeStore() + await Promise.all([store.bump(), store.bump(), store.bump()]) + // Each call read 0 before any of them wrote, so two increments vanished — + // the same way two `mergeExportTxInfo` calls discarded each other's keys. + expect(store.value()).toBe(1) + }) + + it('serializes operations sharing a key', async () => { + const store = makeStore() + await Promise.all([ + serializeByKey('k', store.bump), + serializeByKey('k', store.bump), + serializeByKey('k', store.bump) + ]) + expect(store.value()).toBe(3) + }) + + it('does not serialize across different keys', async () => { + const order: string[] = [] + const slow = async (): Promise => { + await new Promise(resolve => setTimeout(resolve, 20)) + order.push('slow') + } + const quick = async (): Promise => { + order.push('quick') + } + await Promise.all([serializeByKey('a', slow), serializeByKey('b', quick)]) + expect(order).toStrictEqual(['quick', 'slow']) + }) + + it('a failure does not poison the queue behind it', async () => { + const store = makeStore() + const failed = serializeByKey('k', async () => { + throw new Error('boom') + }) + const after = serializeByKey('k', store.bump) + await expect(failed).rejects.toThrow('boom') + await after + expect(store.value()).toBe(1) + }) + + it('returns the operation value and drops the key when idle', async () => { + await expect(serializeByKey('k', async () => 42)).resolves.toBe(42) + expect(pendingKeyCount()).toBe(0) + }) +}) diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index 0ece8f2055e..f83bbe23dd9 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -1,29 +1,15 @@ -import { eq } from 'biggystring' -import type { - EdgeAccount, - EdgeAssetAction, - EdgeAssetAmount, - EdgeCurrencyWallet, - EdgeMetadata, - EdgeTransaction, - EdgeTxAction -} from 'edge-core-js' -import { sprintf } from 'sprintf-js' +import type { EdgeAccount } from 'edge-core-js' import { showError } from '../components/services/AirshipInstance' import { EDGE_CONTENT_SERVER_URI } from '../constants/CdnConstants' -import { TX_ACTION_LABEL_MAP } from '../constants/txActionConstants' import { lstrings } from '../locales/strings' import type { ThunkAction } from '../types/reduxTypes' -import { getCurrencyCodeWithAccount } from '../util/CurrencyInfoHelpers' -import { cleanFiatCurrencyCode } from '../util/CurrencyWalletHelpers' +import type { EdgeCategory } from '../util/txDisplay' -export type Category = 'transfer' | 'exchange' | 'expense' | 'income' - -export interface EdgeCategory { - category: Category - subcategory: string -} +// No re-exports of the Node-safe helpers. This module imports `showError` +// from Airship, so re-exporting them made it the public door to code that +// exists precisely so it can load without react-native. Callers import from +// `util/txDisplay` directly. /** * Use these strings to show categories in a user's language. @@ -71,43 +57,6 @@ export function setNewSubcategory( } } -/** - * Splits a string into its category and subcategory strings. - * The category must fit our enum type, or we will use a fallback. - * The subcategory can be localized and freely edited. - */ -export function splitCategory( - fullCategory: string = '', - defaultCategory: Category = 'income' -): EdgeCategory { - if (fullCategory.length > 0 && !fullCategory.includes(':')) { - fullCategory += ':' - } - for (const [category, test, n] of tests) { - if (test.test(fullCategory)) { - return { - category, - subcategory: fullCategory.slice(n) - } - } - } - - // We can't guarantee that data on disk is correct, - // but this should usually never happen: - return { - category: defaultCategory, - subcategory: fullCategory.replace(/^[^:]:/, '') - } -} - -/** - * Combine the category and subcategory into a single string, - * with the correct capitalization. - */ -export function joinCategory(split: EdgeCategory): string { - return prefixes[split.category] + split.subcategory -} - /** * Localizes a category string for display. */ @@ -116,23 +65,6 @@ export function formatCategory(split: EdgeCategory): string { return `${displayCategories[split.category]}:${split.subcategory}` } -/** - * Internal prefixes used on disk. - */ -const prefixes = { - transfer: 'Transfer:', - exchange: 'Exchange:', - expense: 'Expense:', - income: 'Income:' -} - -const tests: Array<[Category, RegExp, number]> = [ - ['transfer', /^Transfer:/i, 9], - ['exchange', /^Exchange:/i, 9], - ['expense', /^Expense:/i, 8], - ['income', /^Income:/i, 7] -] - export interface CategoriesFile { categories: string[] } @@ -284,427 +216,6 @@ export const defaultCategories = [ 'Transfer:Dark Wallet' ] -/** - * Given an EdgeTxAction, returns the display value for pre-filling the - * 'Category' and 'Notes' tiles, if they are not already user-modified. - */ - -export interface ActionDisplayInfo { - direction: 'send' | 'receive' - iconPluginId?: string - userData: EdgeMetadata - savedData: EdgeMetadata - mergedData: EdgeMetadata - action?: EdgeTxAction - assetAction?: EdgeAssetAction -} - -export const getTxActionDisplayInfo = ( - tx: EdgeTransaction, - account: EdgeAccount, - wallet: EdgeCurrencyWallet -): ActionDisplayInfo => { - const { - assetAction, - chainAction, - chainAssetAction, - metadata, - savedAction, - swapData, - tokenId - } = tx - const { currencyConfig, currencyInfo } = wallet - - const displayName = - tokenId == null - ? currencyInfo.assetDisplayName - : currencyConfig.allTokens[tokenId]?.displayName ?? '' - - const action = savedAction ?? chainAction - const assetAct = assetAction ?? chainAssetAction - - const getCurrencyCodes = (assets: EdgeAssetAmount[]): string[] => - assets - .map(asset => - getCurrencyCodeWithAccount(account, asset.pluginId, asset.tokenId) - ) - .filter((currencyCode): currencyCode is string => currencyCode != null) - - const isSentTransaction = - tx.nativeAmount.startsWith('-') || (eq(tx.nativeAmount, '0') && tx.isSend) - - let payeeText: string | undefined - let edgeCategory: EdgeCategory - let direction: 'send' | 'receive' - let notes: string | undefined - let iconPluginId: string | undefined - - // Default text for send or receive - if (isSentTransaction) { - payeeText = sprintf(lstrings.transaction_sent_1s, displayName) - direction = 'send' - edgeCategory = { - category: 'expense', - subcategory: '' - } - } else { - payeeText = sprintf(lstrings.transaction_received_1s, displayName) - direction = 'receive' - edgeCategory = { - category: 'income', - subcategory: '' - } - } - - // Override with swapData - if (swapData != null) { - const { payoutCurrencyCode } = swapData - payeeText = sprintf( - lstrings.transaction_details_swap_to_subcat_1s, - payoutCurrencyCode - ) - } - - if (action != null && assetAct != null) { - const { actionType } = action - const { assetActionType } = assetAct - payeeText = TX_ACTION_LABEL_MAP[assetActionType] - - let unsupported = false - - switch (actionType) { - case 'swap': { - iconPluginId = action.swapInfo.pluginId - switch (assetActionType) { - case 'transfer': { - const txSrc = action.payoutWalletId !== wallet.id - const toFromStr = txSrc - ? lstrings.transaction_details_swap_to_subcat_1s - : lstrings.transaction_details_swap_from_subcat_1s - const walletName = - account.currencyWallets[action.payoutWalletId]?.name ?? - displayName - edgeCategory = { - category: 'transfer', - subcategory: sprintf(toFromStr, walletName) - } - break - } - case 'transferNetworkFee': - case 'swapNetworkFee': { - edgeCategory = { - category: 'expense', - subcategory: lstrings.wc_smartcontract_network_fee - } - break - } - case 'swap': - case 'swapOrderFill': { - // Determine if the swap destination was to a different asset or if the - // swap source was from a different asset. - const txSrcSameAsset = - action.fromAsset.tokenId === tokenId && - action.fromAsset.pluginId === wallet.currencyInfo.pluginId - const toFromStr = txSrcSameAsset - ? lstrings.transaction_details_swap_to_subcat_1s - : lstrings.transaction_details_swap_from_subcat_1s - const otherAsset = txSrcSameAsset - ? action.toAsset - : action.fromAsset - - edgeCategory = { - category: 'exchange', - subcategory: sprintf( - toFromStr, - getCurrencyCodeWithAccount( - account, - otherAsset.pluginId, - otherAsset.tokenId - ) - ) - } - direction = txSrcSameAsset ? 'send' : 'receive' - break - } - - case 'swapOrderPost': { - edgeCategory = { - category: 'expense', - subcategory: sprintf(lstrings.transaction_details_swap_order_post) - } - direction = 'send' - break - } - case 'swapOrderCancel': { - edgeCategory = { - category: 'expense', - subcategory: sprintf( - lstrings.transaction_details_swap_order_cancel - ) - } - direction = 'send' - break - } - default: - unsupported = true - } - break - } - case 'stake': { - iconPluginId = action.pluginId - switch (assetActionType) { - case 'stake': { - let subcategory - if (action.stakeAssets.length === 1) - subcategory = sprintf( - lstrings.transaction_details_stake_subcat_1s, - ...getCurrencyCodes(action.stakeAssets) - ) - else if (action.stakeAssets.length === 2) - subcategory = sprintf( - lstrings.transaction_details_stake_subcat_2s, - ...getCurrencyCodes(action.stakeAssets) - ) - else { - console.warn( - `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` - ) - break - } - edgeCategory = { category: 'transfer', subcategory } - direction = 'send' - break - } - case 'stakeOrder': { - if (action.stakeAssets.length === 1) - notes = sprintf( - lstrings.transaction_details_unstake_order_notes_1s, - ...getCurrencyCodes(action.stakeAssets) - ) - else if (action.stakeAssets.length === 2) - notes = sprintf( - lstrings.transaction_details_unstake_order_notes_2s, - ...getCurrencyCodes(action.stakeAssets) - ) - else { - console.error( - `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` - ) - break - } - - edgeCategory = { - category: 'expense', - subcategory: lstrings.transaction_details_stake_order_subcat - } - direction = 'send' - break - } - case 'claim': { - let subcategory - if (action.stakeAssets.length === 1) - subcategory = sprintf( - lstrings.transaction_details_unstake_subcat_1s, - ...getCurrencyCodes(action.stakeAssets) - ) - else if (action.stakeAssets.length === 2) - subcategory = sprintf( - lstrings.transaction_details_unstake_subcat_2s, - ...getCurrencyCodes(action.stakeAssets) - ) - else { - console.error( - `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` - ) - break - } - edgeCategory = { category: 'transfer', subcategory } - if ( - action.stakeAssets.every( - asset => asset.pluginId === currencyInfo.pluginId - ) - ) { - direction = 'receive' - } else { - direction = 'send' - } - break - } - case 'unstake': { - let subcategory - if (action.stakeAssets.length === 1) - subcategory = sprintf( - lstrings.transaction_details_unstake_subcat_1s, - ...getCurrencyCodes(action.stakeAssets) - ) - else if (action.stakeAssets.length === 2) - subcategory = sprintf( - lstrings.transaction_details_unstake_subcat_2s, - ...getCurrencyCodes(action.stakeAssets) - ) - else { - console.error( - `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` - ) - break - } - edgeCategory = { category: 'transfer', subcategory } - direction = 'receive' - break - } - case 'claimOrder': - case 'unstakeOrder': { - if (action.stakeAssets.length === 1) - notes = sprintf( - lstrings.transaction_details_unstake_order_notes_1s, - ...getCurrencyCodes(action.stakeAssets) - ) - else if (action.stakeAssets.length === 2) - notes = sprintf( - lstrings.transaction_details_unstake_order_notes_2s, - ...getCurrencyCodes(action.stakeAssets) - ) - else { - console.error( - `Unsupported number of assets for '${assetActionType}' EdgeTxActionSwapType` - ) - break - } - - edgeCategory = { - category: 'expense', - subcategory: lstrings.transaction_details_unstake_order - } - direction = 'send' - break - } - case 'unstakeNetworkFee': - case 'stakeNetworkFee': { - edgeCategory = { - category: 'expense', - subcategory: lstrings.wc_smartcontract_network_fee - } - break - } - - default: - unsupported = true - } - break - } - case 'fiat': { - iconPluginId = action.fiatPlugin.providerId - switch (assetActionType) { - case 'buy': { - payeeText = sprintf(payeeText, displayName) - const { fiatAsset } = action - const { fiatCurrencyCode } = cleanFiatCurrencyCode( - fiatAsset.fiatCurrencyCode - ) - edgeCategory = { - category: 'exchange', - subcategory: sprintf( - lstrings.transaction_details_swap_from_subcat_1s, - fiatCurrencyCode - ) - } - direction = 'receive' - break - } - case 'sell': { - payeeText = sprintf(payeeText, displayName) - const { fiatAsset } = action - const { fiatCurrencyCode } = cleanFiatCurrencyCode( - fiatAsset.fiatCurrencyCode - ) - edgeCategory = { - category: 'exchange', - subcategory: sprintf( - lstrings.transaction_details_swap_to_subcat_1s, - fiatCurrencyCode - ) - } - direction = 'send' - break - } - case 'sellNetworkFee': { - edgeCategory = { - category: 'expense', - subcategory: lstrings.wc_smartcontract_network_fee - } - direction = 'send' - break - } - default: - unsupported = true - } - break - } - case 'tokenApproval': { - switch (assetActionType) { - case 'tokenApproval': { - edgeCategory = { - category: 'expense', - subcategory: lstrings.wc_smartcontract_network_fee - } - break - } - default: - unsupported = true - } - break - } - case 'giftCard': { - iconPluginId = action.provider.providerId - payeeText = lstrings.gift_card_recipient_name - edgeCategory = { - category: 'expense', - subcategory: action.card.name - } - direction = 'send' - break - } - default: - unsupported = true - } - - if (unsupported) - console.error( - `Unsupported EdgeTxAction assetAction:assetActionType '${assetActionType}'` - ) - } - const savedData: EdgeMetadata = { - name: payeeText, - category: joinCategory(edgeCategory), - notes - } - - const mergedData: EdgeMetadata = { - name: - metadata?.name != null && metadata.name.length > 0 - ? metadata.name - : savedData.name, - category: - metadata?.category != null && metadata.category.length > 0 - ? metadata.category - : savedData.category, - notes: - metadata?.notes != null && metadata.notes.length > 0 - ? metadata.notes - : savedData.notes - } - - return { - action, - assetAction, - direction, - iconPluginId, - savedData, - userData: metadata ?? {}, - mergedData - } -} - export const pluginIdIcons: Record = { '0xgasless': EDGE_CONTENT_SERVER_URI + '/0xgasless.png', bitrefill: EDGE_CONTENT_SERVER_URI + '/bitrefill.png', diff --git a/src/actions/TransactionExportActions.tsx b/src/actions/TransactionExportActions.tsx index 5ebace768a9..e0c3f87f4df 100644 --- a/src/actions/TransactionExportActions.tsx +++ b/src/actions/TransactionExportActions.tsx @@ -1,529 +1,32 @@ -import { abs, add, div, gt, lt, mul } from 'biggystring' -import csvStringify from 'csv-stringify/lib/browser/sync' import type { EdgeCurrencyWallet, EdgeTokenId, EdgeTransaction } from 'edge-core-js' -import shajs from 'sha.js' -import { getExchangeDenom } from '../selectors/DenominationSelectors' import type { ThunkAction } from '../types/reduxTypes' -import { getHistoricalCryptoRate } from '../util/exchangeRates' -import { DECIMAL_PRECISION } from '../util/utils' +import { fillTxsFiat } from '../util/fillTxsFiat' -const UPDATE_TXS_MAX_PROMISES = 10 - -export async function exportTransactionsToCSV( - wallet: EdgeCurrencyWallet, - defaultIsoFiat: string, - txs: EdgeTransaction[], - currencyCode: string, - denomination?: string -): Promise { - let denomName = '' - if (denomination != null) { - const denomObj = wallet.currencyInfo.denominations.find( - edgeDenom => edgeDenom.multiplier === denomination - ) - if (denomObj != null) denomName = denomObj.name - } - return exportTransactionsToCSVInner( - txs, - currencyCode, - defaultIsoFiat, - denomination, - denomName - ) -} +export { + exportTransactionsToBitwave, + exportTransactionsToCSV, + exportTransactionsToCSVInner, + exportTransactionsToQBO, + getTransferTx +} from '../util/txExport' export function updateTxsFiat( wallet: EdgeCurrencyWallet, tokenId: EdgeTokenId, - currencyCode: string, txs: EdgeTransaction[] ): ThunkAction> { return async (dispatch, getState) => { - const state = getState() - const defaultIsoFiat = state.ui.settings.defaultIsoFiat - - const exchangeDenom = getExchangeDenom(wallet.currencyConfig, tokenId) - - let promises: Array> = [] - for (const tx of txs) { - const amountFiat = tx.metadata?.exchangeAmount?.[defaultIsoFiat] ?? 0 - - if (amountFiat === 0) { - const date = new Date(tx.date * 1000).toISOString() - promises.push( - getHistoricalCryptoRate( - wallet.currencyInfo.pluginId, - tokenId, - defaultIsoFiat, - date - ) - .then(rate => { - tx.metadata = { - ...tx.metadata, - exchangeAmount: { - ...tx.metadata?.exchangeAmount, - [defaultIsoFiat]: - rate * - Number( - div( - tx.nativeAmount, - exchangeDenom.multiplier, - DECIMAL_PRECISION - ) - ) - } - } - }) - .catch((e: unknown) => { - console.warn(e instanceof Error ? e.message : String(e)) - }) - ) - if (promises.length >= UPDATE_TXS_MAX_PROMISES) { - await Promise.all(promises) - promises = [] - } - } - } - if (promises.length > 0) { - await Promise.all(promises) - } - } -} - -function padZero(val: string): string { - if (val.length === 1) { - return '0' + val - } - return val -} - -function escapeOFXString(str: string): string { - str = str.replace(/&/g, '&') - str = str.replace(/>/g, '>') - return str.replace(/${element}\n` - } else if (element instanceof Array) { - for (const a of element) { - out += `<${key}>\n` - out += exportOfxBody(a) - out += `\n` - } - } else if (typeof element === 'object') { - out += `<${key}>\n` - out += exportOfxBody(element) - out += `\n` - } else { - throw new Error('Invalid OFX body') - } - } - return out -} - -function exportOfx(header: any, body: any): string { - let out = exportOfxHeader(header) + '\n' - out += '\n' - out += exportOfxBody(body) - out += '\n' - return out -} - -function makeOfxDate(date: number): string { - const d = new Date(date * 1000) - const yyyy = d.getUTCFullYear().toString() - const mm = padZero((d.getUTCMonth() + 1).toString()) - const dd = padZero(d.getUTCDate().toString()) - const hh = padZero(d.getUTCHours().toString()) - const min = padZero(d.getUTCMinutes().toString()) - const ss = padZero(d.getUTCSeconds().toString()) - return `${yyyy}${mm}${dd}${hh}${min}${ss}.000` -} - -function makeCsvDateTime(date: number): { date: string; time: string } { - const d = new Date(date * 1000) - const yyyy = d.getUTCFullYear().toString() - const mm = padZero((d.getUTCMonth() + 1).toString()) - const dd = padZero(d.getUTCDate().toString()) - const hh = padZero(d.getUTCHours().toString()) - const min = padZero(d.getUTCMinutes().toString()) - - return { - date: `${yyyy}-${mm}-${dd}`, - time: `${hh}:${min}` - } -} - -/** ISO 8601 UTC, the timestamp format Bitwave requires for imports. */ -function makeBitwaveDateTime(date: number): string { - const d = new Date(date * 1000) - const yyyy = d.getUTCFullYear().toString() - const mm = padZero((d.getUTCMonth() + 1).toString()) - const dd = padZero(d.getUTCDate().toString()) - const hh = padZero(d.getUTCHours().toString()) - const min = padZero(d.getUTCMinutes().toString()) - const ss = padZero(d.getUTCSeconds().toString()) - - return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}Z` -} - -// -// Check if tx is -// 1. A transfer -// 2. Outgoing spend -// 3. Has a network fee -// If so: -// 1. Modify transaction to reduce the nativeAmount by the networkFee -// 2. Set networkFee to 0 -// 3. Return a new transaction that has: -// 1. nativeAmount and networkFee set to original tx fee -// 2. category set to 'Expense:Network Fee' -// 3. txid set to old txid + '-TRANSFER_TX' - -export function getTransferTx( - oldEdgeTransaction: EdgeTransaction, - fiatCurrencyCode: string -): EdgeTransaction[] | null { - const edgeTransaction = { ...oldEdgeTransaction } - edgeTransaction.metadata = { ...oldEdgeTransaction.metadata } - - const category = edgeTransaction.metadata?.category ?? '' - if (!category.toLowerCase().startsWith('transfer:')) return null - if (!lt(edgeTransaction.nativeAmount, '0')) return null - if (!gt(edgeTransaction.networkFee, '0')) return null - - const nativeAmountNoFee = add( - edgeTransaction.nativeAmount, - edgeTransaction.networkFee - ) - let newTxFiatFee = 0 - let amountFiat = - edgeTransaction.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 - - if (amountFiat > 0) { - const exchangeRate: string = div( - amountFiat.toString(), - edgeTransaction.nativeAmount, - 16 - ) - const newTxFiatFeeString: string = mul( - exchangeRate, - edgeTransaction.networkFee - ) - newTxFiatFee = Math.abs(Number(newTxFiatFeeString)) - amountFiat = Number(mul(exchangeRate, nativeAmountNoFee)) - } - - const newEdgeTransaction: EdgeTransaction = { ...edgeTransaction } - newEdgeTransaction.nativeAmount = `-${edgeTransaction.networkFee}` - newEdgeTransaction.metadata = { - ...edgeTransaction.metadata, - category: `Expense:Network Fee`, - exchangeAmount: { - ...edgeTransaction.metadata?.exchangeAmount, - [fiatCurrencyCode]: newTxFiatFee - } - } - newEdgeTransaction.txid += '-TRANSFER_TX' - edgeTransaction.nativeAmount = nativeAmountNoFee - edgeTransaction.networkFee = '0' - edgeTransaction.metadata = { - ...edgeTransaction.metadata, - exchangeAmount: { - ...edgeTransaction.metadata?.exchangeAmount, - [fiatCurrencyCode]: amountFiat - } - } - return [edgeTransaction, newEdgeTransaction] -} - -export function exportTransactionsToQBO( - edgeTransactions: EdgeTransaction[], - fiatCurrencyCode: string, - denom: string | undefined, - /** For unit testing */ - testDateNow?: number -): string { - const STMTTRN: any[] = [] - const now = makeOfxDate((testDateNow ?? Date.now()) / 1000) - const hasDenom = denom != null - - for (const tx of edgeTransactions) { - const newTxs = getTransferTx(tx, fiatCurrencyCode) - if (newTxs != null) { - edgeTxToQbo(newTxs[0]) - edgeTxToQbo(newTxs[1]) - } else { - edgeTxToQbo(tx) - } - } - - function edgeTxToQbo(edgeTx: EdgeTransaction): void { - const TRNAMT: string = hasDenom - ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) - : edgeTx.nativeAmount - const TRNTYPE = lt(edgeTx.nativeAmount, '0') ? 'DEBIT' : 'CREDIT' - const DTPOSTED = makeOfxDate(edgeTx.date) - const NAME: string = edgeTx.metadata?.name ?? '' - const amountFiat: number = - edgeTx.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 - const category: string = edgeTx.metadata?.category ?? '' - const notes: string = edgeTx.metadata?.notes ?? '' - - const absFiat = abs(amountFiat.toString()) - const absAmount = abs(TRNAMT) - const CURRATE = absAmount !== '0' ? div(absFiat, absAmount, 8) : '0' - let memo = `// Rate=${CURRATE} ${fiatCurrencyCode}=${amountFiat} category="${category}" memo="${notes}"` - if (memo.length > 250) { - memo = memo.substring(0, 250) + '...' - } - const qboTxNamed = { - TRNTYPE, - DTPOSTED, - TRNAMT, - FITID: edgeTx.txid, - NAME, - MEMO: memo, - CURRENCY: { - CURRATE, - CURSYM: fiatCurrencyCode - } - } - const qboTx = { - TRNTYPE, - DTPOSTED, - TRNAMT, - FITID: edgeTx.txid, - MEMO: memo, - CURRENCY: { - CURRATE, - CURSYM: fiatCurrencyCode - } - } - const use = NAME === '' ? qboTx : qboTxNamed - STMTTRN.push(use) - } - - const header = { - OFXHEADER: '100', - DATA: 'OFXSGML', - VERSION: '102', - SECURITY: 'NONE', - ENCODING: 'USASCII', - CHARSET: '1252', - COMPRESSION: 'NONE', - OLDFILEUID: 'NONE', - NEWFILEUID: 'NONE' - } - - const body = { - SIGNONMSGSRSV1: { - SONRS: { - STATUS: { - CODE: '0', - SEVERITY: 'INFO' - }, - DTSERVER: now, - LANGUAGE: 'ENG', - 'INTU.BID': '3000' - } - }, - BANKMSGSRSV1: { - STMTTRNRS: { - TRNUID: now, - STATUS: { - CODE: '0', - SEVERITY: 'INFO', - MESSAGE: 'OK' - }, - STMTRS: { - CURDEF: 'USD', - BANKACCTFROM: { - BANKID: '999999999', - ACCTID: '999999999999', - ACCTTYPE: 'CHECKING' - }, - BANKTRANLIST: { - DTSTART: now, - DTEND: now, - STMTTRN - }, - LEDGERBAL: { - BALAMT: '0.00', - DTASOF: now - }, - AVAILBAL: { - BALAMT: '0.00', - DTASOF: now - } - } - } - } - } - - return exportOfx(header, body) -} - -export function exportTransactionsToCSVInner( - edgeTransactions: EdgeTransaction[], - currencyCode: string, - fiatCurrencyCode: string, - denom?: string, - denomName: string = '' -): string { - const items: any[] = [] - const hasDenom = denom != null - - for (const tx of edgeTransactions) { - const newTxs = getTransferTx(tx, fiatCurrencyCode) - if (newTxs != null) { - edgeTxToCsv(newTxs[0]) - edgeTxToCsv(newTxs[1]) - } else { - edgeTxToCsv(tx) - } - } - - function edgeTxToCsv(edgeTx: EdgeTransaction): void { - const amount: string = hasDenom - ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) - : edgeTx.nativeAmount - const networkFeeField: string = hasDenom - ? div(edgeTx.networkFee, denom, DECIMAL_PRECISION) - : edgeTx.networkFee - const { date, time } = makeCsvDateTime(edgeTx.date) - const name: string = edgeTx.metadata?.name ?? '' - const amountFiat: number = - edgeTx.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 - const category: string = edgeTx.metadata?.category ?? '' - const notes: string = edgeTx.metadata?.notes ?? '' - - items.push({ - CURRENCY_CODE: currencyCode, - DATE: date, - TIME: time, - PAYEE_PAYER_NAME: name, - AMT_ASSET: amount, - DENOMINATION: denomName, - [fiatCurrencyCode]: String(amountFiat), - CATEGORY: category, - NOTES: notes, - AMT_NETWORK_FEES_ASSET: networkFeeField, - TXID: edgeTx.txid, - OUR_RECEIVE_ADDRESSES: edgeTx.ourReceiveAddresses.join(','), - VER: 1, - DEVICE_DESCRIPTION: edgeTx.deviceDescription ?? '' + const defaultIsoFiat = getState().ui.settings.defaultIsoFiat + await fillTxsFiat({ + wallet, + tokenId, + isoFiat: defaultIsoFiat, + txs }) } - - return csvStringify(items, { - header: true, - quoted_string: true, - record_delimiter: '\n' - }) -} - -export async function exportTransactionsToBitwave( - accountId: string, - edgeTransactions: EdgeTransaction[], - currencyCode: string, - multiplier: string -): Promise { - const items: any[] = [] - - for (const tx of edgeTransactions) { - edgeTxToCsv(tx) - } - - function edgeTxToCsv(edgeTx: EdgeTransaction): void { - const { - date, - isSend, - metadata, - nativeAmount, - networkFee, - ourReceiveAddresses, - spendTargets, - txid - } = edgeTx - const amount: string = abs(div(nativeAmount, multiplier, DECIMAL_PRECISION)) - const time = makeBitwaveDateTime(date) - const { name = '', category = '', notes = '' } = metadata ?? {} - - let toAddress = '' - if (isSend) { - if (spendTargets != null && spendTargets.length > 0) { - // We can only choose 1 `toAddress` so pick the first spendTarget - toAddress = spendTargets[0].publicAddress - } - } else { - // We can only choose 1 `toAddress` so pick the first receive address - toAddress = ourReceiveAddresses != null ? ourReceiveAddresses[0] : '' - } - - const id = shajs('sha256') - .update(`${txid}_${nativeAmount}_${networkFee}_${toAddress}`) - .digest('hex') - .slice(0, 16) - - items.push({ - id, - remoteContactId: '', - amount, - amountTicker: currencyCode, - cost: '', - costTicker: '', - // Bitwave calculates transaction fees on its own side. Exporting them - // here duplicates the fees after import, so both columns stay blank: - fee: '', - feeTicker: '', - time, - blockchainId: txid, - memo: notes, - transactionType: isSend ? 'withdrawal' : 'deposit', - accountId, - contactId: '', - categoryId: '', - taxExempt: 'FALSE', - tradeId: '', - description: name, - fromAddress: '', - toAddress, - groupId: '', - 'metadata:myCustomMetadata1': category, - // Bitwave expects this to mirror the description column: - 'metadata:myCustomMetadata2': name - }) - } - - return csvStringify(items, { - header: true, - quoted_string: true, - record_delimiter: '\n' - }) } diff --git a/src/components/scenes/TransactionsExportScene.tsx b/src/components/scenes/TransactionsExportScene.tsx index 3ea018673e3..fc0d2a70381 100644 --- a/src/components/scenes/TransactionsExportScene.tsx +++ b/src/components/scenes/TransactionsExportScene.tsx @@ -1,4 +1,3 @@ -import { asBoolean, asObject, asString } from 'cleaners' import type { EdgeAccount, EdgeCurrencyWallet, @@ -11,7 +10,6 @@ import RNFS from 'react-native-fs' import Share from 'react-native-share' import EntypoIcon from 'react-native-vector-icons/Entypo' -import { getTxActionDisplayInfo } from '../../actions/CategoriesActions' import { exportTransactionsToBitwave, exportTransactionsToCSV, @@ -28,6 +26,14 @@ import { connect } from '../../types/reactRedux' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { + EXPORT_TX_INFO_FILE, + type ExportTxInfo, + exportTxInfoKey, + mergeExportTxInfo, + readExportTxInfoMap +} from '../../util/exportTxInfo' +import { getTxActionDisplayInfo } from '../../util/txDisplay' import { SceneWrapper } from '../common/SceneWrapper' import { DateModal } from '../modals/DateModal' import { TextInputModal } from '../modals/TextInputModal' @@ -64,7 +70,6 @@ interface DispatchProps { updateTxsFiatDispatch: ( wallet: EdgeCurrencyWallet, tokenId: EdgeTokenId, - currencyCode: string, txs: EdgeTransaction[] ) => Promise } @@ -79,20 +84,6 @@ interface State { isExportBitwave: boolean } -const EXPORT_TX_INFO_FILE = 'exportTxInfo.json' - -const asExportTxInfo = asObject({ - bitwaveAccountId: asString, - isExportQbo: asBoolean, - isExportCsv: asBoolean, - isExportBitwave: asBoolean -}) - -const asExportTxInfoMap = asObject(asExportTxInfo) - -type ExportTxInfoMap = ReturnType -type ExportTxInfo = ReturnType - class TransactionsExportSceneComponent extends React.PureComponent< Props, State @@ -165,13 +156,12 @@ class TransactionsExportSceneComponent extends React.PureComponent< loadInfoFile = async (): Promise => { const { sourceWallet, tokenId } = this.props.route.params - const { disklet } = sourceWallet - const result = await disklet.getText(EXPORT_TX_INFO_FILE) - const exportTxInfoMap = asExportTxInfoMap(JSON.parse(result)) - const tokenCurrencyCode = tokenId ?? sourceWallet.currencyInfo.currencyCode + const exportTxInfoMap = await readExportTxInfoMap(sourceWallet) + const tokenCurrencyCode = exportTxInfoKey(sourceWallet, tokenId) + const info = exportTxInfoMap[tokenCurrencyCode] + if (info == null) return - const { isExportBitwave, isExportCsv, isExportQbo } = - exportTxInfoMap[tokenCurrencyCode] + const { isExportBitwave, isExportCsv, isExportQbo } = info this.setState({ isExportBitwave, @@ -307,13 +297,11 @@ class TransactionsExportSceneComponent extends React.PureComponent< const { sourceWallet, tokenId } = route.params const { isExportBitwave, isExportQbo, isExportCsv, startDate, endDate } = this.state - const tokenCurrencyCode = tokenId ?? sourceWallet.currencyInfo.currencyCode + const tokenCurrencyCode = exportTxInfoKey(sourceWallet, tokenId) let exportTxInfo: ExportTxInfo | undefined - let exportTxInfoMap: ExportTxInfoMap | undefined try { - const result = await sourceWallet.disklet.getText(EXPORT_TX_INFO_FILE) - exportTxInfoMap = asExportTxInfoMap(JSON.parse(result)) + const exportTxInfoMap = await readExportTxInfoMap(sourceWallet) exportTxInfo = exportTxInfoMap[tokenCurrencyCode] } catch (e) { console.log( @@ -357,17 +345,12 @@ class TransactionsExportSceneComponent extends React.PureComponent< exportTxInfo?.isExportCsv !== isExportCsv || exportTxInfo?.isExportQbo !== isExportQbo ) { - exportTxInfoMap ??= {} - exportTxInfoMap[tokenCurrencyCode] = { + await mergeExportTxInfo(sourceWallet, tokenId, { bitwaveAccountId: accountId, isExportBitwave, isExportQbo, isExportCsv - } - await sourceWallet.disklet.setText( - EXPORT_TX_INFO_FILE, - JSON.stringify(exportTxInfoMap) - ) + }) } if (startDate.getTime() > endDate.getTime()) { @@ -413,12 +396,7 @@ class TransactionsExportSceneComponent extends React.PureComponent< const formats: string[] = [] // Update the transactions that are missing fiat amounts - await this.props.updateTxsFiatDispatch( - sourceWallet, - tokenId, - currencyCode, - txs - ) + await this.props.updateTxsFiatDispatch(sourceWallet, tokenId, txs) // The non-string result appears to be a bug in the core, // which we are relying on to determine if the date range is empty: @@ -549,8 +527,8 @@ export const TransactionsExportScene = connect< ).multiplier }), dispatch => ({ - updateTxsFiatDispatch: async (wallet, tokenId, currencyCode, txs) => { - await dispatch(updateTxsFiat(wallet, tokenId, currencyCode, txs)) + updateTxsFiatDispatch: async (wallet, tokenId, txs) => { + await dispatch(updateTxsFiat(wallet, tokenId, txs)) } }) )(withTheme(TransactionsExportSceneComponent)) diff --git a/src/constants/txActionConstants.ts b/src/constants/txActionConstants.ts deleted file mode 100644 index 13d811cb8ec..00000000000 --- a/src/constants/txActionConstants.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { EdgeAssetActionType } from 'edge-core-js' - -import { lstrings } from '../locales/strings' - -export const TX_ACTION_LABEL_MAP: Record = { - buy: lstrings.transaction_details_bought_1s, - claim: lstrings.transaction_details_claim, - claimOrder: lstrings.transaction_details_claim_order, - giftCard: lstrings.transaction_details_gift_card, - sell: lstrings.transaction_details_sold_1s, - sellNetworkFee: lstrings.fiat_plugin_sell_network_fee, - swap: lstrings.transaction_details_swap, - swapNetworkFee: lstrings.transaction_details_swap_network_fee, - swapOrderPost: lstrings.transaction_details_swap_order_post, - swapOrderFill: lstrings.transaction_details_swap_order_fill, - swapOrderCancel: lstrings.transaction_details_swap_order_cancel, - stake: lstrings.transaction_details_stake, - stakeNetworkFee: lstrings.transaction_details_stake_network_fee, - stakeOrder: lstrings.transaction_details_stake_order, - tokenApproval: lstrings.transaction_details_token_approval, - transfer: lstrings.transaction_details_transfer_funds, - transferNetworkFee: lstrings.transaction_details_transfer_network_fee, - unstake: lstrings.transaction_details_unstake, - unstakeNetworkFee: lstrings.transaction_details_unstake_network_fee, - unstakeOrder: lstrings.transaction_details_unstake_order -} diff --git a/src/util/exportTxInfo.ts b/src/util/exportTxInfo.ts new file mode 100644 index 00000000000..d0c2be4a64c --- /dev/null +++ b/src/util/exportTxInfo.ts @@ -0,0 +1,112 @@ +import { + asBoolean, + asJSON, + asMaybe, + asObject, + asString, + uncleaner +} from 'cleaners' +import type { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js' + +import { isMissingFile } from './predicates' +import { serializeByKey } from './serializeByKey' + +/** Per-wallet, per-asset export prefs on `wallet.disklet`. */ +export const EXPORT_TX_INFO_FILE = 'exportTxInfo.json' + +export const asExportTxInfo = asObject({ + bitwaveAccountId: asString, + isExportQbo: asBoolean, + isExportCsv: asBoolean, + isExportBitwave: asBoolean +}) + +export const asExportTxInfoMap = asObject(asExportTxInfo) + +const uncleanExportTxInfoMap = uncleaner(asExportTxInfoMap) + +export type ExportTxInfo = ReturnType +export type ExportTxInfoMap = ReturnType + +/** + * Map key is `tokenId ?? currencyCode` (native = currency code; token = + * contract tokenId). Matches the GUI export scene. + */ +export function exportTxInfoKey( + wallet: Pick, + tokenId: EdgeTokenId +): string { + return tokenId ?? wallet.currencyInfo.currencyCode +} + +/** Tolerant of a record this version cannot read; strict about the rest. */ +const asStoredExportTxInfoMap = asJSON(asObject(asMaybe(asExportTxInfo))) + +export async function readExportTxInfoMap( + wallet: Pick +): Promise { + const text = await wallet.disklet.getText(EXPORT_TX_INFO_FILE) + // `asJSON`, so one cleaner owns both the parse and the shape — and + // `asMaybe` per record, so one unreadable asset entry costs that entry + // rather than the Export button. A whole file that will not parse is also + // tolerated, because nothing can be recovered from it and the GUI scene + // recovered by writing a fresh map; a genuine read or decryption failure + // still throws, out of `getText`, and the caller decides. + const map = asMaybe(asStoredExportTxInfoMap)(text) ?? {} + const out: ExportTxInfoMap = {} + for (const [key, value] of Object.entries(map)) { + if (value != null) out[key] = value + } + return out +} + +export async function writeExportTxInfoMap( + wallet: Pick, + map: ExportTxInfoMap +): Promise { + // Through the cleaner's uncleaner: a shape change is then a compile + // error rather than a file `readExportTxInfoMap` later rejects. + await wallet.disklet.setText( + EXPORT_TX_INFO_FILE, + JSON.stringify(uncleanExportTxInfoMap(map)) + ) +} + +/** + * Merge one asset key. Omitted patch fields keep the previous value, or + * `false` / `''` when creating the key. + */ +export async function mergeExportTxInfo( + wallet: EdgeCurrencyWallet, + tokenId: EdgeTokenId, + patch: Partial +): Promise { + // Read-modify-write over the whole file, so two calls for different tokens + // on the same wallet must not interleave: the engine serves requests + // concurrently and the second write would discard the first. + return await serializeByKey(`exportTxInfo:${wallet.id}`, async () => { + let map: ExportTxInfoMap + try { + map = await readExportTxInfoMap(wallet) + } catch (error: unknown) { + // Only an absent file may be answered with an empty map. A bad shape + // never reaches here — `readExportTxInfoMap` drops the records it + // cannot read — so anything caught here is a real I/O or decryption + // failure, and answering *that* with a rewrite would turn one + // unreadable file into the loss of every asset's saved preferences. + if (!isMissingFile(error)) throw error + map = {} + } + const key = exportTxInfoKey(wallet, tokenId) + const prev = map[key] + const next: ExportTxInfo = { + bitwaveAccountId: patch.bitwaveAccountId ?? prev?.bitwaveAccountId ?? '', + isExportBitwave: patch.isExportBitwave ?? prev?.isExportBitwave ?? false, + isExportCsv: patch.isExportCsv ?? prev?.isExportCsv ?? false, + isExportQbo: patch.isExportQbo ?? prev?.isExportQbo ?? false + } + map[key] = next + await writeExportTxInfoMap(wallet, map) + return next + }) +} diff --git a/src/util/fillTxsFiat.ts b/src/util/fillTxsFiat.ts new file mode 100644 index 00000000000..69fdf2b4aa4 --- /dev/null +++ b/src/util/fillTxsFiat.ts @@ -0,0 +1,86 @@ +import { div } from 'biggystring' +import type { + EdgeCurrencyWallet, + EdgeTokenId, + EdgeTransaction +} from 'edge-core-js' + +import { getExchangeDenom } from './exchangeDenom' +import { getHistoricalCryptoRate } from './exchangeRates' +import { DECIMAL_PRECISION } from './utils' + +/** + * Accept a 3-letter ISO 4217 code (`USD`, `eur`) or `iso:USD`. + * Returns `iso:USD` or undefined when the input is not a fiat code. + */ +export function toIsoFiatCode(raw: string): string | undefined { + let code = raw.trim().toUpperCase() + if (code.startsWith('ISO:')) code = code.slice(4) + if (!/^[A-Z]{3}$/.test(code)) return undefined + return `iso:${code}` +} + +/** + * Fill missing `metadata.exchangeAmount[isoFiat]` from the rates server, + * using each transaction's date. Skips a transaction whose fiat amount is + * already non-zero, and persists nothing. + * + * This is the loop `updateTxsFiat` used to run inline; + * `TransactionExportActions` is now a short thunk that reads `defaultIsoFiat` + * out of Redux and calls this, so there is no parallel implementation to keep + * in step. + * + * Every rate is queued before anything is awaited. `getHistoricalCryptoRate` + * batches into one request of up to RATES_SERVER_MAX_QUERY_SIZE assets and + * debounces by FETCH_FREQUENCY per batch, so awaiting in fixed-size groups + * instead bought a fresh debounce every group: 1,200 unpriced transactions + * cost ~120s of pure waiting in groups of ten, past the CLI client's own + * socket timeout, against ~1.1s queued all at once. + */ +export async function fillTxsFiat(opts: { + wallet: EdgeCurrencyWallet + tokenId: EdgeTokenId + isoFiat: string + txs: EdgeTransaction[] +}): Promise { + const { wallet, tokenId, isoFiat, txs } = opts + const exchangeDenom = getExchangeDenom(wallet.currencyConfig, tokenId) + + const promises: Array> = [] + for (const tx of txs) { + const amountFiat = tx.metadata?.exchangeAmount?.[isoFiat] ?? 0 + + if (amountFiat === 0) { + const date = new Date(tx.date * 1000).toISOString() + promises.push( + getHistoricalCryptoRate( + wallet.currencyInfo.pluginId, + tokenId, + isoFiat, + date + ) + .then(rate => { + tx.metadata = { + ...tx.metadata, + exchangeAmount: { + ...tx.metadata?.exchangeAmount, + [isoFiat]: + rate * + Number( + div( + tx.nativeAmount, + exchangeDenom.multiplier, + DECIMAL_PRECISION + ) + ) + } + } + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : String(error)) + }) + ) + } + } + await Promise.all(promises) +} diff --git a/src/util/serializeByKey.ts b/src/util/serializeByKey.ts new file mode 100644 index 00000000000..276b1d8d667 --- /dev/null +++ b/src/util/serializeByKey.ts @@ -0,0 +1,38 @@ +/** + * Run async operations one at a time per key. + * + * The engine serves requests concurrently, so a read-modify-write over a + * whole shared file — a wallet's `exportTxInfo.json`, an account's + * `Settings.json` — can interleave with another and the second write + * silently discards the first. Disklet has no compare-and-swap, so the + * serialization has to live here. + * + * A failed operation does not poison the queue behind it, and a key is + * dropped once nothing is waiting on it, so the map stays bounded. + */ +const chains = new Map>() + +export async function serializeByKey( + key: string, + operation: () => Promise +): Promise { + const previous = chains.get(key) ?? Promise.resolve() + const run = previous.then(operation) + const tail = run.then( + () => {}, + () => {} + ) + chains.set(key, tail) + try { + return await run + } finally { + // Only the last operation in the chain clears the key; an earlier one + // finishing must not drop a queue that is still being appended to. + if (chains.get(key) === tail) chains.delete(key) + } +} + +/** How many keys currently have work queued. Tests read this. */ +export function pendingKeyCount(): number { + return chains.size +} diff --git a/src/util/txExport/format.ts b/src/util/txExport/format.ts new file mode 100644 index 00000000000..d87f7e7f014 --- /dev/null +++ b/src/util/txExport/format.ts @@ -0,0 +1,490 @@ +import { abs, add, div, gt, lt, mul } from 'biggystring' +import csvStringify from 'csv-stringify/lib/browser/sync' +import type { EdgeCurrencyWallet, EdgeTransaction } from 'edge-core-js' +import shajs from 'sha.js' + +import { DECIMAL_PRECISION } from '../utils' + +export async function exportTransactionsToCSV( + wallet: EdgeCurrencyWallet, + defaultIsoFiat: string, + txs: EdgeTransaction[], + currencyCode: string, + denomination?: string +): Promise { + let denomName = '' + if (denomination != null) { + const denomObj = wallet.currencyInfo.denominations.find( + edgeDenom => edgeDenom.multiplier === denomination + ) + if (denomObj != null) denomName = denomObj.name + } + return exportTransactionsToCSVInner( + txs, + currencyCode, + defaultIsoFiat, + denomination, + denomName + ) +} + +function padZero(val: string): string { + if (val.length === 1) { + return '0' + val + } + return val +} + +/** + * Escape a string for OFX 1.x, and keep it inside the declared charset. + * + * The header declares `ENCODING:USASCII` / `CHARSET:1252`, which is what + * every accounting importer this file has ever been fed expects. A payee or + * memo can hold anything — a dapp sets it through `edgeProvider`, the CLI's + * `--metadata` accepts any string, and the engine's own localized prose is + * not ASCII in most languages — so the non-ASCII characters are turned into + * SGML numeric character references rather than emitted raw. The declaration + * is then true, and an importer that reads the references shows the original + * character. + */ +function escapeOFXString(str: string): string { + str = str.replace(/&/g, '&') + str = str.replace(/>/g, '>') + str = str.replace(/ `&#${char.codePointAt(0) ?? 0};` + ) +} + +function exportOfxHeader(inputObj: any): string { + let out = '' + for (const key of Object.keys(inputObj)) { + let element = inputObj[key] + if (typeof element === 'string') { + element = escapeOFXString(element) + out += `${key}:${element}\n` + } else { + throw new Error('Invalid OFX header') + } + } + return out +} + +function exportOfxBody(inputObj: any): string { + let out = '' + for (const key of Object.keys(inputObj)) { + let element = inputObj[key] + if (typeof element === 'string') { + element = escapeOFXString(element) + out += `<${key}>${element}\n` + } else if (element instanceof Array) { + for (const a of element) { + out += `<${key}>\n` + out += exportOfxBody(a) + out += `\n` + } + } else if (typeof element === 'object') { + out += `<${key}>\n` + out += exportOfxBody(element) + out += `\n` + } else { + throw new Error('Invalid OFX body') + } + } + return out +} + +function exportOfx(header: any, body: any): string { + let out = exportOfxHeader(header) + '\n' + out += '\n' + out += exportOfxBody(body) + out += '\n' + return out +} + +function makeOfxDate(date: number): string { + const d = new Date(date * 1000) + const yyyy = d.getUTCFullYear().toString() + const mm = padZero((d.getUTCMonth() + 1).toString()) + const dd = padZero(d.getUTCDate().toString()) + const hh = padZero(d.getUTCHours().toString()) + const min = padZero(d.getUTCMinutes().toString()) + const ss = padZero(d.getUTCSeconds().toString()) + return `${yyyy}${mm}${dd}${hh}${min}${ss}.000` +} + +function makeCsvDateTime(date: number): { date: string; time: string } { + const d = new Date(date * 1000) + const yyyy = d.getUTCFullYear().toString() + const mm = padZero((d.getUTCMonth() + 1).toString()) + const dd = padZero(d.getUTCDate().toString()) + const hh = padZero(d.getUTCHours().toString()) + const min = padZero(d.getUTCMinutes().toString()) + + return { + date: `${yyyy}-${mm}-${dd}`, + time: `${hh}:${min}` + } +} + +/** ISO 8601 UTC, the timestamp format Bitwave requires for imports. */ +function makeBitwaveDateTime(date: number): string { + const d = new Date(date * 1000) + const yyyy = d.getUTCFullYear().toString() + const mm = padZero((d.getUTCMonth() + 1).toString()) + const dd = padZero(d.getUTCDate().toString()) + const hh = padZero(d.getUTCHours().toString()) + const min = padZero(d.getUTCMinutes().toString()) + const ss = padZero(d.getUTCSeconds().toString()) + + return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}Z` +} + +// +// Check if tx is +// 1. A transfer +// 2. Outgoing spend +// 3. Has a network fee +// If so: +// 1. Modify transaction to reduce the nativeAmount by the networkFee +// 2. Set networkFee to 0 +// 3. Return a new transaction that has: +// 1. nativeAmount and networkFee set to original tx fee +// 2. category set to 'Expense:Network Fee' +// 3. txid set to old txid + '-TRANSFER_TX' + +export function getTransferTx( + oldEdgeTransaction: EdgeTransaction, + fiatCurrencyCode: string +): EdgeTransaction[] | null { + const edgeTransaction = { ...oldEdgeTransaction } + edgeTransaction.metadata = { ...oldEdgeTransaction.metadata } + + const category = edgeTransaction.metadata?.category ?? '' + if (!category.toLowerCase().startsWith('transfer:')) return null + if (!lt(edgeTransaction.nativeAmount, '0')) return null + if (!gt(edgeTransaction.networkFee, '0')) return null + + const nativeAmountNoFee = add( + edgeTransaction.nativeAmount, + edgeTransaction.networkFee + ) + let newTxFiatFee = 0 + let amountFiat = + edgeTransaction.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 + + if (amountFiat > 0) { + const exchangeRate: string = div( + amountFiat.toString(), + edgeTransaction.nativeAmount, + 16 + ) + const newTxFiatFeeString: string = mul( + exchangeRate, + edgeTransaction.networkFee + ) + newTxFiatFee = Math.abs(Number(newTxFiatFeeString)) + amountFiat = Number(mul(exchangeRate, nativeAmountNoFee)) + } + + const newEdgeTransaction: EdgeTransaction = { ...edgeTransaction } + newEdgeTransaction.nativeAmount = `-${edgeTransaction.networkFee}` + newEdgeTransaction.metadata = { + ...edgeTransaction.metadata, + category: `Expense:Network Fee`, + exchangeAmount: { + ...edgeTransaction.metadata?.exchangeAmount, + [fiatCurrencyCode]: newTxFiatFee + } + } + newEdgeTransaction.txid += '-TRANSFER_TX' + edgeTransaction.nativeAmount = nativeAmountNoFee + edgeTransaction.networkFee = '0' + edgeTransaction.metadata = { + ...edgeTransaction.metadata, + exchangeAmount: { + ...edgeTransaction.metadata?.exchangeAmount, + [fiatCurrencyCode]: amountFiat + } + } + return [edgeTransaction, newEdgeTransaction] +} + +export function exportTransactionsToQBO( + edgeTransactions: EdgeTransaction[], + fiatCurrencyCode: string, + denom: string | undefined, + /** For unit testing */ + testDateNow?: number +): string { + const STMTTRN: any[] = [] + const now = makeOfxDate((testDateNow ?? Date.now()) / 1000) + const hasDenom = denom != null + + for (const tx of edgeTransactions) { + const newTxs = getTransferTx(tx, fiatCurrencyCode) + if (newTxs != null) { + edgeTxToQbo(newTxs[0]) + edgeTxToQbo(newTxs[1]) + } else { + edgeTxToQbo(tx) + } + } + + function edgeTxToQbo(edgeTx: EdgeTransaction): void { + const TRNAMT: string = hasDenom + ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) + : edgeTx.nativeAmount + const TRNTYPE = lt(edgeTx.nativeAmount, '0') ? 'DEBIT' : 'CREDIT' + const DTPOSTED = makeOfxDate(edgeTx.date) + const NAME: string = edgeTx.metadata?.name ?? '' + const amountFiat: number = + edgeTx.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 + const category: string = edgeTx.metadata?.category ?? '' + const notes: string = edgeTx.metadata?.notes ?? '' + + const absFiat = abs(amountFiat.toString()) + const absAmount = abs(TRNAMT) + const CURRATE = absAmount !== '0' ? div(absFiat, absAmount, 8) : '0' + let memo = `// Rate=${CURRATE} ${fiatCurrencyCode}=${amountFiat} category="${category}" memo="${notes}"` + if (memo.length > 250) { + memo = memo.substring(0, 250) + '...' + } + const qboTxNamed = { + TRNTYPE, + DTPOSTED, + TRNAMT, + FITID: edgeTx.txid, + NAME, + MEMO: memo, + CURRENCY: { + CURRATE, + CURSYM: fiatCurrencyCode + } + } + const qboTx = { + TRNTYPE, + DTPOSTED, + TRNAMT, + FITID: edgeTx.txid, + MEMO: memo, + CURRENCY: { + CURRATE, + CURSYM: fiatCurrencyCode + } + } + const use = NAME === '' ? qboTx : qboTxNamed + STMTTRN.push(use) + } + + // `USASCII`/`1252`, as the app has always written, and `escapeOFXString` + // now makes the declaration true instead of the header being changed to + // match the bytes. + // + // The defect was narrow: only a file carrying a non-ASCII payee or memo + // was ever mis-described, and the engine's localized prose made that + // reachable. Moving to `ENCODING:UNICODE` changed the format on 100% of + // exports, including the ASCII-only majority, inside a commit whose stated + // scope is a Node-safety extraction with no behaviour change — and OFX 1.x + // `UNICODE` is not unambiguously UTF-8 (readers have taken it as UCS-2), + // so an importer that honours the declaration could mis-decode the whole + // file where before it garbled only the non-ASCII characters. + const header = { + OFXHEADER: '100', + DATA: 'OFXSGML', + VERSION: '102', + SECURITY: 'NONE', + ENCODING: 'USASCII', + CHARSET: '1252', + COMPRESSION: 'NONE', + OLDFILEUID: 'NONE', + NEWFILEUID: 'NONE' + } + + const body = { + SIGNONMSGSRSV1: { + SONRS: { + STATUS: { + CODE: '0', + SEVERITY: 'INFO' + }, + DTSERVER: now, + LANGUAGE: 'ENG', + 'INTU.BID': '3000' + } + }, + BANKMSGSRSV1: { + STMTTRNRS: { + TRNUID: now, + STATUS: { + CODE: '0', + SEVERITY: 'INFO', + MESSAGE: 'OK' + }, + STMTRS: { + CURDEF: 'USD', + BANKACCTFROM: { + BANKID: '999999999', + ACCTID: '999999999999', + ACCTTYPE: 'CHECKING' + }, + BANKTRANLIST: { + DTSTART: now, + DTEND: now, + STMTTRN + }, + LEDGERBAL: { + BALAMT: '0.00', + DTASOF: now + }, + AVAILBAL: { + BALAMT: '0.00', + DTASOF: now + } + } + } + } + } + + return exportOfx(header, body) +} + +export function exportTransactionsToCSVInner( + edgeTransactions: EdgeTransaction[], + currencyCode: string, + fiatCurrencyCode: string, + denom?: string, + denomName: string = '' +): string { + const items: any[] = [] + const hasDenom = denom != null + + for (const tx of edgeTransactions) { + const newTxs = getTransferTx(tx, fiatCurrencyCode) + if (newTxs != null) { + edgeTxToCsv(newTxs[0]) + edgeTxToCsv(newTxs[1]) + } else { + edgeTxToCsv(tx) + } + } + + function edgeTxToCsv(edgeTx: EdgeTransaction): void { + const amount: string = hasDenom + ? div(edgeTx.nativeAmount, denom, DECIMAL_PRECISION) + : edgeTx.nativeAmount + const networkFeeField: string = hasDenom + ? div(edgeTx.networkFee, denom, DECIMAL_PRECISION) + : edgeTx.networkFee + const { date, time } = makeCsvDateTime(edgeTx.date) + const name: string = edgeTx.metadata?.name ?? '' + const amountFiat: number = + edgeTx.metadata?.exchangeAmount?.[fiatCurrencyCode] ?? 0 + const category: string = edgeTx.metadata?.category ?? '' + const notes: string = edgeTx.metadata?.notes ?? '' + + items.push({ + CURRENCY_CODE: currencyCode, + DATE: date, + TIME: time, + PAYEE_PAYER_NAME: name, + AMT_ASSET: amount, + DENOMINATION: denomName, + [fiatCurrencyCode]: String(amountFiat), + CATEGORY: category, + NOTES: notes, + AMT_NETWORK_FEES_ASSET: networkFeeField, + TXID: edgeTx.txid, + OUR_RECEIVE_ADDRESSES: edgeTx.ourReceiveAddresses.join(','), + VER: 1, + DEVICE_DESCRIPTION: edgeTx.deviceDescription ?? '' + }) + } + + return csvStringify(items, { + header: true, + quoted_string: true, + record_delimiter: '\n' + }) +} + +export async function exportTransactionsToBitwave( + accountId: string, + edgeTransactions: EdgeTransaction[], + currencyCode: string, + multiplier: string +): Promise { + const items: any[] = [] + + for (const tx of edgeTransactions) { + edgeTxToCsv(tx) + } + + function edgeTxToCsv(edgeTx: EdgeTransaction): void { + const { + date, + isSend, + metadata, + nativeAmount, + networkFee, + ourReceiveAddresses, + spendTargets, + txid + } = edgeTx + const amount: string = abs(div(nativeAmount, multiplier, DECIMAL_PRECISION)) + const time = makeBitwaveDateTime(date) + const { name = '', category = '', notes = '' } = metadata ?? {} + + let toAddress = '' + if (isSend) { + if (spendTargets != null && spendTargets.length > 0) { + // We can only choose 1 `toAddress` so pick the first spendTarget + toAddress = spendTargets[0].publicAddress + } + } else { + // We can only choose 1 `toAddress` so pick the first receive address + toAddress = ourReceiveAddresses != null ? ourReceiveAddresses[0] : '' + } + + const id = shajs('sha256') + .update(`${txid}_${nativeAmount}_${networkFee}_${toAddress}`) + .digest('hex') + .slice(0, 16) + + items.push({ + id, + remoteContactId: '', + amount, + amountTicker: currencyCode, + cost: '', + costTicker: '', + // Bitwave calculates transaction fees on its own side. Exporting them + // here duplicates the fees after import, so both columns stay blank: + fee: '', + feeTicker: '', + time, + blockchainId: txid, + memo: notes, + transactionType: isSend ? 'withdrawal' : 'deposit', + accountId, + contactId: '', + categoryId: '', + taxExempt: 'FALSE', + tradeId: '', + description: name, + fromAddress: '', + toAddress, + groupId: '', + 'metadata:myCustomMetadata1': category, + // Bitwave expects this to mirror the description column: + 'metadata:myCustomMetadata2': name + }) + } + + return csvStringify(items, { + header: true, + quoted_string: true, + record_delimiter: '\n' + }) +} diff --git a/src/util/txExport/index.ts b/src/util/txExport/index.ts new file mode 100644 index 00000000000..7e27ee7c3fd --- /dev/null +++ b/src/util/txExport/index.ts @@ -0,0 +1,41 @@ +import { asValue } from 'cleaners' + +export { + exportTransactionsToBitwave, + exportTransactionsToCSV, + exportTransactionsToCSVInner, + exportTransactionsToQBO, + getTransferTx +} from './format' + +const TX_EXPORT_FORMATS = ['csv', 'qbo', 'bitwave'] as const +export type TxExportFormat = (typeof TX_EXPORT_FORMATS)[number] + +/** + * One export format. + * + * A cleaner rather than `includes(part as TxExportFormat)`: the cast was + * what made that check load-bearing, so a new format added to + * `TX_EXPORT_FORMATS` and forgotten here would have been accepted silently. + */ +export const asTxExportFormat = asValue(...TX_EXPORT_FORMATS) + +/** + * Parse a comma-separated exportFormat query/flag. + * Empty / omitted → `[]`. Unknown tokens throw. + */ +export function parseExportFormats(raw: string | undefined): TxExportFormat[] { + if (raw == null) return [] + const parts = raw + .split(',') + .map(part => part.trim().toLowerCase()) + .filter(part => part !== '') + const formats: TxExportFormat[] = [] + for (const part of parts) { + // Reports the field and the legal values, where the hand-rolled check + // said only `Unknown exportFormat "x"`. + const format = asTxExportFormat(part) + if (!formats.includes(format)) formats.push(format) + } + return formats +} From a5219cc71f3da07d0ca4af4914313bd3c7145005 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:08 -0700 Subject: [PATCH 09/24] Extract Node-safe transaction tagging `SendScene2` saved a sent transaction and then attached its metadata, its category, its notes and any swap details in a sequence that had to happen in a particular order and had grown inline in the scene. A second caller that saved a transaction and got the order wrong would produce a transaction that looks right until someone exports it. `txTagging/apply` holds that sequence. `SendScene2` calls it and loses two dozen lines. The scene was the only definition of what a correctly tagged transaction is, and now it is not the only caller that can produce one. It re-applies only the fields the caller actually supplied. Under `EdgeMetadataChange` an empty string is a value rather than "leave unchanged", so passing all three through would let a caller who set one field erase the other two that core derived. The trigger is any non-empty name, notes or category, which is wider than the scene's old `payeeName != null` check and preserves a category that would otherwise be lost; callers pass the metadata they were given, never computed display metadata. --- src/__tests__/util/txTagging.test.ts | 119 +++++++++++++++++++++++++++ src/components/scenes/SendScene2.tsx | 28 ++----- src/util/txTagging/apply.ts | 85 +++++++++++++++++++ src/util/txTagging/index.ts | 1 + 4 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/util/txTagging.test.ts create mode 100644 src/util/txTagging/apply.ts create mode 100644 src/util/txTagging/index.ts diff --git a/src/__tests__/util/txTagging.test.ts b/src/__tests__/util/txTagging.test.ts new file mode 100644 index 00000000000..a7a93eace07 --- /dev/null +++ b/src/__tests__/util/txTagging.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { + EdgeCurrencyWallet, + EdgeSaveTxMetadataOptions, + EdgeTransaction +} from 'edge-core-js' + +import { + hasPersistableTxMetadata, + saveTxAndMetadata +} from '../../util/txTagging' + +function makeWallet(opts?: { saveTxMetadataError?: Error }): { + wallet: EdgeCurrencyWallet + saveTx: jest.Mock<() => Promise> + saveTxMetadata: jest.Mock<(opts: EdgeSaveTxMetadataOptions) => Promise> +} { + const saveTx = jest.fn<() => Promise>(async () => {}) + const saveTxMetadata = jest.fn< + (opts: EdgeSaveTxMetadataOptions) => Promise + >(async () => { + if (opts?.saveTxMetadataError != null) throw opts.saveTxMetadataError + }) + return { + wallet: { saveTx, saveTxMetadata } as unknown as EdgeCurrencyWallet, + saveTx, + saveTxMetadata + } +} + +const tx = (metadata?: EdgeTransaction['metadata']): EdgeTransaction => + ({ txid: 'abc', tokenId: null, metadata } as unknown as EdgeTransaction) + +describe('hasPersistableTxMetadata', () => { + it('is false for nothing worth re-applying', () => { + expect(hasPersistableTxMetadata(undefined)).toBe(false) + expect(hasPersistableTxMetadata({})).toBe(false) + expect( + hasPersistableTxMetadata({ name: '', notes: '', category: '' }) + ).toBe(false) + }) + + it('is true for any one supplied field, including a category', () => { + expect(hasPersistableTxMetadata({ name: 'Alice' })).toBe(true) + expect(hasPersistableTxMetadata({ notes: 'rent' })).toBe(true) + expect(hasPersistableTxMetadata({ category: 'Expense:Food' })).toBe(true) + }) +}) + +describe('saveTxAndMetadata', () => { + it('saves the transaction and nothing else when there is no metadata', async () => { + const { wallet, saveTx, saveTxMetadata } = makeWallet() + await saveTxAndMetadata(wallet, tx()) + expect(saveTx).toHaveBeenCalledTimes(1) + expect(saveTxMetadata).not.toHaveBeenCalled() + }) + + it('re-applies only the fields the caller supplied', async () => { + const { wallet, saveTxMetadata } = makeWallet() + await saveTxAndMetadata(wallet, tx({ notes: 'rent' })) + + // An empty string would *clear* the field core derived, so absent fields + // must be absent from the change object rather than ''. + expect(saveTxMetadata).toHaveBeenCalledWith({ + txid: 'abc', + tokenId: null, + metadata: { notes: 'rent' } + }) + }) + + it('does not clear a sibling field that was passed empty', async () => { + const { wallet, saveTxMetadata } = makeWallet() + await saveTxAndMetadata(wallet, tx({ notes: 'rent', name: '' })) + + const [{ metadata }] = saveTxMetadata.mock.calls[0] + expect(metadata).toEqual({ notes: 'rent' }) + expect('name' in metadata).toBe(false) + }) + + it('carries all three when all three are supplied', async () => { + const { wallet, saveTxMetadata } = makeWallet() + await saveTxAndMetadata( + wallet, + tx({ name: 'Alice', notes: 'rent', category: 'Expense:Rent' }) + ) + const [{ metadata }] = saveTxMetadata.mock.calls[0] + expect(metadata).toEqual({ + name: 'Alice', + notes: 'rent', + category: 'Expense:Rent' + }) + }) + + it('routes a metadata failure to onMetadataError instead of throwing', async () => { + const failure = new Error('sync failed') + const { wallet } = makeWallet({ saveTxMetadataError: failure }) + const onMetadataError = jest.fn<(error: unknown) => void>() + + await saveTxAndMetadata(wallet, tx({ name: 'Alice' }), { onMetadataError }) + expect(onMetadataError).toHaveBeenCalledWith(failure) + }) + + it('throws a metadata failure when no handler is given', async () => { + const failure = new Error('sync failed') + const { wallet } = makeWallet({ saveTxMetadataError: failure }) + await expect(saveTxAndMetadata(wallet, tx({ name: 'Alice' }))).rejects.toBe( + failure + ) + }) + + it('never reports a failed broadcast as success', async () => { + const { wallet, saveTx, saveTxMetadata } = makeWallet() + saveTx.mockRejectedValue(new Error('disk full') as never) + await expect( + saveTxAndMetadata(wallet, tx({ name: 'Alice' })) + ).rejects.toThrow('disk full') + expect(saveTxMetadata).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index b94fb33b123..7003bea1a3a 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -68,6 +68,7 @@ import { getMemoLabel, getMemoTitle } from '../../util/memoUtils' +import { saveTxAndMetadata } from '../../util/txTagging' import { convertTransactionFeeToDisplayFee, darkenHexColor, @@ -1516,28 +1517,11 @@ const SendComponent: React.FC = props => { spendTargets: ${JSON.stringify(spendInfo.spendTargets)} ourReceiveAddresses: ${JSON.stringify(ourReceiveAddresses)}`) - await coreWallet.saveTx(broadcastedTx) - - // edge-core-js's saveTx silently drops tx.metadata when the engine - // has already registered the txid in walletState before we get here - // (race against the engine's onTransactionsChanged callback, which - // calls setupNewTxMetadata with no metadata for the engine's view of - // the tx). Re-apply via saveTxMetadata so payeeName and fio notes - // survive a reload from disk. - if (payeeName != null) { - await coreWallet - .saveTxMetadata({ - txid: broadcastedTx.txid, - tokenId: broadcastedTx.tokenId, - metadata: { - name: broadcastedTx.metadata.name, - notes: broadcastedTx.metadata.notes - } - }) - .catch((error: unknown) => { - showError(error) - }) - } + await saveTxAndMetadata(coreWallet, broadcastedTx, { + onMetadataError: (error: unknown) => { + showError(error) + } + }) for (const target of spendInfo.spendTargets) { // Write FIO OBT per spendTarget diff --git a/src/util/txTagging/apply.ts b/src/util/txTagging/apply.ts new file mode 100644 index 00000000000..795f7e75e2a --- /dev/null +++ b/src/util/txTagging/apply.ts @@ -0,0 +1,85 @@ +import type { + EdgeCurrencyWallet, + EdgeMetadata, + EdgeMetadataChange, + EdgeTransaction +} from 'edge-core-js' + +/** A field the caller actually supplied, rather than an empty placeholder. */ +function nonEmpty(value: string | undefined): boolean { + return value != null && value !== '' +} + +/** + * True when the caller supplied metadata worth re-applying — a payee name + * from BIP21 or a resolved address, notes, or an explicit category. + * + * A category counts. Callers must therefore pass the metadata they were given, + * not the display metadata computed for a transaction, whose `Expense:` / + * `Income:` category would make every send look like it carried one. + */ +export function hasPersistableTxMetadata( + metadata: EdgeMetadata | undefined +): boolean { + if (metadata == null) return false + return ( + nonEmpty(metadata.name) || + nonEmpty(metadata.notes) || + nonEmpty(metadata.category) + ) +} + +/** + * saveTx plus the saveTxMetadata re-apply used by SendScene2 and the CLI. + * + * Core derives its own metadata for a sent transaction, and a concurrent + * engine callback can land after ours and drop what the caller asked for. So + * the fields the caller actually supplied are written again afterwards. + * + * Only non-empty fields are sent. Under `EdgeMetadataChange` an empty string + * is a value, not "leave unchanged" — only `undefined` means that, and `null` + * deletes — so sending all three verbatim let a caller who supplied one field + * erase the other two. + * + * saveTx errors always throw. saveTxMetadata errors throw unless + * `onMetadataError` is provided (the GUI uses that so a tagging failure + * cannot look like a failed send after broadcast). + * + * This fires for a category or a note, not only for a payee name. The GUI + * used to re-apply `if (payeeName != null)`, and the race its own comment + * describes — core's `saveTx` dropping `tx.metadata` when the engine has + * already registered the txid — drops *every* field, not just the name. So a + * category arriving from a payment URI was silently losable before. The cost + * is one extra round trip on a send that carries a category but no payee, + * which buys back a field that could otherwise vanish on reload. + */ +export async function saveTxAndMetadata( + wallet: EdgeCurrencyWallet, + tx: EdgeTransaction, + opts?: { + onMetadataError?: (error: unknown) => void + } +): Promise { + await wallet.saveTx(tx) + const metadata = tx.metadata + if (!hasPersistableTxMetadata(metadata)) return + + const change: EdgeMetadataChange = {} + if (nonEmpty(metadata?.name)) change.name = metadata?.name + if (nonEmpty(metadata?.notes)) change.notes = metadata?.notes + if (nonEmpty(metadata?.category)) change.category = metadata?.category + + try { + await wallet.saveTxMetadata({ + txid: tx.txid, + tokenId: tx.tokenId, + metadata: change + }) + } catch (error: unknown) { + if (opts?.onMetadataError != null) { + opts.onMetadataError(error) + return + } + throw error + } +} diff --git a/src/util/txTagging/index.ts b/src/util/txTagging/index.ts new file mode 100644 index 00000000000..8899a7e6a5e --- /dev/null +++ b/src/util/txTagging/index.ts @@ -0,0 +1 @@ +export { hasPersistableTxMetadata, saveTxAndMetadata } from './apply' From 05f24feb413ef342dcc984f686d312253c8647f8 Mon Sep 17 00:00:00 2001 From: Paul Puey Date: Thu, 1 Oct 2026 10:56:09 -0700 Subject: [PATCH 10/24] Add the Edge CLI engine and its REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long-lived engine daemon owns the `EdgeContext` and answers a JSON REST API over a Unix socket; the `edge-cli` binary is a thin one-shot client that spawns the engine on demand and keeps a session id in `session.json` so commands chain. `docs/EDGE_CLI.md` describes that architecture and deliberately documents no endpoints — the reference is generated. The point of this commit is the declaration format, so it carries thirteen calls. Each is one `route({…})`: the core call it fronts, the HTTP method and path, how it appears on the command line, cleaners for the query, body and response, and its error codes. The prose lives inside the declaration, beside the field it describes, and the JSDoc above carries what belongs to the call as a whole. Nothing is written twice. The command line, the help text, the OpenAPI document and the HTML reference are all derived from these declarations, and the derived artifacts are committed so a fresh clone needs no build step. Five gates run in the pre-commit hook and reject the ways they could drift apart: a route with no command, a handler reading a field its cleaner would strip, a request parameter the core call does not have, a generated file that is stale, and a command no test exercises. The thirteen cover the shapes worth reviewing: - no arguments, engine-local — `engine-status`, `engine-config` - no arguments, reaching core — `local-users`, `fetch-login-messages` - one named argument — `username-available` - a body, and the session it establishes — `create-account`, `login-with-password`, `logout` - a positional path parameter — `object-get`, `object-delete` - a held-open stream — `subscribe` Path parameters are base58 identifiers and nothing else, because base64 wallet ids and free-text usernames contain `/` and cannot survive a URL unescaped. Everything else is a named argument. A positional is declared once as an ordinary field and the path is derived from it, so the two cannot disagree. `--fake` serves an in-process `makeFakeEdgeWorld`, which is what lets the CLI tests run in a hook with no network, no server and no API key. Core values with methods on them cannot cross JSON, so the engine keeps them and hands back a handle: a staged transaction, a pending login, a swap quote, a lobby. A handle carries its own TTL and is released when the caller finishes with it, and a call that consumes one — approving a swap — marks it in flight first, so a client that retries after its own socket timeout is refused with `OBJECT_IN_USE` rather than spending twice. A call that keeps its handle holds it the same way, so a broadcast that outlives the TTL still returns its txid instead of expiring between the send and the reply. Reading a handle returns a projection, never the live object: serializing an `EdgeAccount` would walk its `otpKey` and `recoveryKey` getters, and a swap quote reaches both wallets and every token they know. Responses are validated against the same cleaners that document them. `checkResponse` runs each one and discards the cleaned value, since a cleaner strips unknown keys and returning it would delete fields the engine means to send; `EDGE_CLI_CHECK_RESPONSES` decides whether a mismatch warns, fails the request, or is skipped. Drift shows up in the log rather than reaching a caller unnoticed. One engine serves one profile, and it claims the profile by creating its run file exclusively before opening the data directory, so two cold invocations cannot hold two `EdgeContext`s on one set of repos or unlink each other's socket. The idle timer counts in-flight requests as well as sessions and subscribers, so a cold login cannot be shut down underneath itself. Everything the engine reads from disk — its run file, the client's session file, the account's synced settings — goes through a cleaner, and the bearer tokens in an OTP challenge are masked on their way to a terminal while staying in the REST body the commands read them from. --- .gitattributes | 6 + .gitignore | 9 + .travis.yml | 7 + AGENTS.md | 21 +- docs/EDGE_CLI.md | 499 + docs/api/README.md | 169 + docs/api/dist/index.html | 8499 +++++++++++++ docs/api/dist/openapi.json | 10227 ++++++++++++++++ docs/api/groups.ts | 217 + docs/api/shared.ts | 365 + native/edge-api-signer/node/binding.gyp | 18 + .../node/edge_api_signer_napi.c | 176 + package-lock.json | 336 +- package.json | 41 +- rollup.config.cli.mjs | 87 + scripts/apiDocs.css | 147 + scripts/buildApiDocs.ts | 800 ++ scripts/buildCliCommands.ts | 136 + scripts/buildCliHelp.ts | 134 + scripts/buildNodeApiSigner.sh | 26 + scripts/checkCliCoverage.ts | 113 + scripts/checkCoreAlignment.ts | 155 + scripts/checkRouteContracts.ts | 181 + scripts/cliNodeSafeSmoke.js | 116 + scripts/cliUsage.ts | 120 + scripts/engineRequest.ts | 59 + scripts/extractRoutes.ts | 862 ++ scripts/makeApiSigner.ts | 102 +- scripts/prepare.sh | 6 + scripts/testCliFake.ts | 998 ++ scripts/testCliSubscribe.ts | 543 + scripts/util/cliHarness.ts | 64 + scripts/util/solveCaptcha.ts | 1 + scripts/verifyApiDocs.ts | 470 + scripts/writeIfChanged.ts | 40 + src/__tests__/cli/apiClient.test.ts | 140 + src/__tests__/cli/configSubsets.test.ts | 82 + src/__tests__/cli/discovery.test.ts | 330 + src/__tests__/cli/engineCodecs.test.ts | 138 + src/__tests__/cli/errors.test.ts | 223 + src/__tests__/cli/events.test.ts | 185 + src/__tests__/cli/exitCodes.test.ts | 69 + src/__tests__/cli/fetchPluginKeys.test.ts | 21 + src/__tests__/cli/idleShutdown.test.ts | 176 + src/__tests__/cli/jsonBody.test.ts | 67 + src/__tests__/cli/keysConfig.test.ts | 17 + src/__tests__/cli/logger.test.ts | 125 + src/__tests__/cli/objectHandles.test.ts | 193 + src/__tests__/cli/parseArgs.test.ts | 229 + src/__tests__/cli/persistedSchemas.test.ts | 183 + src/__tests__/cli/redaction.test.ts | 134 + src/__tests__/cli/routeHelpers.test.ts | 135 + src/__tests__/cli/router.test.ts | 71 + src/__tests__/cli/sessions.test.ts | 354 + src/__tests__/cli/solveCaptcha.test.ts | 129 + src/__tests__/util/exchangeDenom.test.ts | 44 + src/cli/bootEngineLocale.ts | 22 + src/cli/bootNodeLocale.ts | 50 + src/cli/client/apiClient.ts | 493 + src/cli/client/exitCodes.ts | 71 + src/cli/client/output.ts | 93 + src/cli/client/sessionFile.ts | 71 + src/cli/client/solveCaptcha.ts | 197 + src/cli/client/spawnEngine.ts | 304 + src/cli/clientResponses.ts | 58 + src/cli/command.ts | 92 + src/cli/commandArgs.ts | 129 + src/cli/commands/all.ts | 8 + src/cli/commands/generated.ts | 158 + src/cli/commands/help.ts | 46 + src/cli/commands/login.ts | 198 + src/cli/commands/paths.ts | 17 + src/cli/commands/subscribe.ts | 117 + src/cli/declare-modules.d.ts | 1 + src/cli/engine/apiVersion.ts | 14 + src/cli/engine/appConfig.ts | 50 + src/cli/engine/cliConfig.ts | 59 + src/cli/engine/cliHome.ts | 37 + src/cli/engine/discovery.ts | 467 + src/cli/engine/doc.ts | 37 + src/cli/engine/errorGroups.ts | 27 + src/cli/engine/errors.ts | 343 + src/cli/engine/events.ts | 256 + src/cli/engine/fetchPluginKeys.ts | 110 + src/cli/engine/fieldDocs.ts | 38 + src/cli/engine/idleShutdown.ts | 172 + src/cli/engine/index.ts | 700 ++ src/cli/engine/internal.ts | 45 + src/cli/engine/json.ts | 72 + src/cli/engine/keysConfig.ts | 97 + src/cli/engine/logger.ts | 225 + src/cli/engine/makeCoreContext.ts | 400 + src/cli/engine/nodeApiSigner.ts | 97 + src/cli/engine/objectHandles.ts | 381 + src/cli/engine/readJsonConfig.ts | 58 + src/cli/engine/resolve.ts | 88 + src/cli/engine/route.ts | 282 + src/cli/engine/router.ts | 121 + src/cli/engine/routes/account.ts | 401 + src/cli/engine/routes/context.ts | 307 + src/cli/engine/routes/events.ts | 88 + src/cli/engine/routes/helpers.ts | 92 + src/cli/engine/routes/index.ts | 19 + src/cli/engine/routes/login.ts | 546 + src/cli/engine/routes/objects.ts | 128 + src/cli/engine/routes/status.ts | 176 + src/cli/engine/schemas.ts | 890 ++ src/cli/engine/server.ts | 342 + src/cli/engine/sessions.ts | 513 + src/cli/engine/sweepTicker.ts | 31 + src/cli/engine/tcpPort.ts | 41 + src/cli/engine/testerServers.ts | 33 + src/cli/engine/transportAuth.ts | 138 + src/cli/flagTable.ts | 151 + src/cli/generated/commands.json | 2123 ++++ src/cli/generated/helpDocs.json | 3427 ++++++ src/cli/generatedSchemas.ts | 127 + src/cli/index.ts | 442 + src/cli/parseArgs.ts | 210 + src/util/CurrencyInfoHelpers.ts | 30 +- 120 files changed, 45270 insertions(+), 81 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/EDGE_CLI.md create mode 100644 docs/api/README.md create mode 100644 docs/api/dist/index.html create mode 100644 docs/api/dist/openapi.json create mode 100644 docs/api/groups.ts create mode 100644 docs/api/shared.ts create mode 100644 native/edge-api-signer/node/binding.gyp create mode 100644 native/edge-api-signer/node/edge_api_signer_napi.c create mode 100644 rollup.config.cli.mjs create mode 100644 scripts/apiDocs.css create mode 100644 scripts/buildApiDocs.ts create mode 100644 scripts/buildCliCommands.ts create mode 100644 scripts/buildCliHelp.ts create mode 100755 scripts/buildNodeApiSigner.sh create mode 100644 scripts/checkCliCoverage.ts create mode 100644 scripts/checkCoreAlignment.ts create mode 100644 scripts/checkRouteContracts.ts create mode 100644 scripts/cliNodeSafeSmoke.js create mode 100644 scripts/cliUsage.ts create mode 100644 scripts/engineRequest.ts create mode 100644 scripts/extractRoutes.ts create mode 100644 scripts/testCliFake.ts create mode 100644 scripts/testCliSubscribe.ts create mode 100644 scripts/util/cliHarness.ts create mode 100644 scripts/util/solveCaptcha.ts create mode 100644 scripts/verifyApiDocs.ts create mode 100644 scripts/writeIfChanged.ts create mode 100644 src/__tests__/cli/apiClient.test.ts create mode 100644 src/__tests__/cli/configSubsets.test.ts create mode 100644 src/__tests__/cli/discovery.test.ts create mode 100644 src/__tests__/cli/engineCodecs.test.ts create mode 100644 src/__tests__/cli/errors.test.ts create mode 100644 src/__tests__/cli/events.test.ts create mode 100644 src/__tests__/cli/exitCodes.test.ts create mode 100644 src/__tests__/cli/fetchPluginKeys.test.ts create mode 100644 src/__tests__/cli/idleShutdown.test.ts create mode 100644 src/__tests__/cli/jsonBody.test.ts create mode 100644 src/__tests__/cli/keysConfig.test.ts create mode 100644 src/__tests__/cli/logger.test.ts create mode 100644 src/__tests__/cli/objectHandles.test.ts create mode 100644 src/__tests__/cli/parseArgs.test.ts create mode 100644 src/__tests__/cli/persistedSchemas.test.ts create mode 100644 src/__tests__/cli/redaction.test.ts create mode 100644 src/__tests__/cli/routeHelpers.test.ts create mode 100644 src/__tests__/cli/router.test.ts create mode 100644 src/__tests__/cli/sessions.test.ts create mode 100644 src/__tests__/cli/solveCaptcha.test.ts create mode 100644 src/__tests__/util/exchangeDenom.test.ts create mode 100644 src/cli/bootEngineLocale.ts create mode 100644 src/cli/bootNodeLocale.ts create mode 100644 src/cli/client/apiClient.ts create mode 100644 src/cli/client/exitCodes.ts create mode 100644 src/cli/client/output.ts create mode 100644 src/cli/client/sessionFile.ts create mode 100644 src/cli/client/solveCaptcha.ts create mode 100644 src/cli/client/spawnEngine.ts create mode 100644 src/cli/clientResponses.ts create mode 100644 src/cli/command.ts create mode 100644 src/cli/commandArgs.ts create mode 100644 src/cli/commands/all.ts create mode 100644 src/cli/commands/generated.ts create mode 100644 src/cli/commands/help.ts create mode 100644 src/cli/commands/login.ts create mode 100644 src/cli/commands/paths.ts create mode 100644 src/cli/commands/subscribe.ts create mode 100644 src/cli/declare-modules.d.ts create mode 100644 src/cli/engine/apiVersion.ts create mode 100644 src/cli/engine/appConfig.ts create mode 100644 src/cli/engine/cliConfig.ts create mode 100644 src/cli/engine/cliHome.ts create mode 100644 src/cli/engine/discovery.ts create mode 100644 src/cli/engine/doc.ts create mode 100644 src/cli/engine/errorGroups.ts create mode 100644 src/cli/engine/errors.ts create mode 100644 src/cli/engine/events.ts create mode 100644 src/cli/engine/fetchPluginKeys.ts create mode 100644 src/cli/engine/fieldDocs.ts create mode 100644 src/cli/engine/idleShutdown.ts create mode 100644 src/cli/engine/index.ts create mode 100644 src/cli/engine/internal.ts create mode 100644 src/cli/engine/json.ts create mode 100644 src/cli/engine/keysConfig.ts create mode 100644 src/cli/engine/logger.ts create mode 100644 src/cli/engine/makeCoreContext.ts create mode 100644 src/cli/engine/nodeApiSigner.ts create mode 100644 src/cli/engine/objectHandles.ts create mode 100644 src/cli/engine/readJsonConfig.ts create mode 100644 src/cli/engine/resolve.ts create mode 100644 src/cli/engine/route.ts create mode 100644 src/cli/engine/router.ts create mode 100644 src/cli/engine/routes/account.ts create mode 100644 src/cli/engine/routes/context.ts create mode 100644 src/cli/engine/routes/events.ts create mode 100644 src/cli/engine/routes/helpers.ts create mode 100644 src/cli/engine/routes/index.ts create mode 100644 src/cli/engine/routes/login.ts create mode 100644 src/cli/engine/routes/objects.ts create mode 100644 src/cli/engine/routes/status.ts create mode 100644 src/cli/engine/schemas.ts create mode 100644 src/cli/engine/server.ts create mode 100644 src/cli/engine/sessions.ts create mode 100644 src/cli/engine/sweepTicker.ts create mode 100644 src/cli/engine/tcpPort.ts create mode 100644 src/cli/engine/testerServers.ts create mode 100644 src/cli/engine/transportAuth.ts create mode 100644 src/cli/flagTable.ts create mode 100644 src/cli/generated/commands.json create mode 100644 src/cli/generated/helpDocs.json create mode 100644 src/cli/generatedSchemas.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/parseArgs.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..ec2b9b8298f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# The committed generated artifacts. They belong in the tree — a fresh clone +# needs no build step to run the CLI or read its reference — but they are +# output, so a review should not have to scroll them and the language +# statistics should not count them. +docs/api/dist/** linguist-generated=true +src/cli/generated/** linguist-generated=true diff --git a/.gitignore b/.gitignore index da3f7fb27fa..ba6bee559e0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ coverage/ /ios/EdgeApiSecret.h /android/app/src/main/cpp/edge_api_secret.c /android/app/src/main/cpp/edge_api_secret.h +/native/edge-api-signer/node/edge_api_secret.c +/native/edge-api-signer/node/edge_api_secret.h +/native/edge-api-signer/node/build/ /vendor/*.tgz /vendor/edge-core-js-*.tgz /*.tgz @@ -140,3 +143,9 @@ yarn-error.log !.yarn/sdks !.yarn/versions /.husky/_ + +# Edge CLI runtime +.edge-cli/ + +# Built CLI +/lib/ diff --git a/.travis.yml b/.travis.yml index f8edca4305e..bdd0ffb8816 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,4 +15,11 @@ install: script: - npm run lint - npx tsc + # The five documentation gates, which are read-only and take seconds + # between them. `npm run prepare` above regenerates the committed + # artifacts, so without this nothing anywhere notices a stale + # `commands.json` — and that file is the table the CLI parses argv against. + - npm run docs:api:gates + - npm run test:cli:node-safe - npm test + - npm run test:cli:offline diff --git a/AGENTS.md b/AGENTS.md index 24184a3af69..2d808ef765a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,27 @@ - `npm test` - Run Jest tests (single run) - `npm run watch` - Run Jest tests in watch mode - `npm test -- --testNamePattern="test name"` - Run specific test by name -- `npm run verify` - Run lint, typechain, tsc, and test (full verification) -- `npm run precommit` - Full pre-commit check (localize, lint-staged, tsc, test) +- `npm run verify` - Full verification: lint, typechain, tsc, the five + documentation gates, the Node-safety smoke test, Jest, and the CLI's offline + suites against both the sources and the built bundle +- `npm run precommit` - Pre-commit check: localize, update-eslint-warnings, + lint-staged, the documentation gates, the Node-safety smoke test, tsc, Jest + and the CLI's offline suites - `tsc` - TypeScript type checking (via package.json script) +### Edge CLI + +The repository also builds `edge-cli` and `edge-engine` from `src/cli/`. +[`docs/EDGE_CLI.md`](docs/EDGE_CLI.md) is the guide; `docs/api/README.md` +covers the route declarations the reference is generated from. + +- `npm run cli -- ` / `npm run engine` - run either half from source +- `npm run build:cli` - the single-file bundles in `lib/` +- `npm run docs:api` - regenerate the committed reference and command table +- `npm run docs:api:gates` - the five read-only checks that it is in step +- `npm run test:cli:offline` - the fake-world suites, no network +- `npm run test:cli:network` - the suites that need the tester servers + ## Swap Provider Integration The plugin itself lives in `edge-exchange-plugins`; this repo only wires it up, and every wiring point below fails SILENTLY when missed (no error, just a blank icon or a provider that never initializes). Registering a new swap `pluginId` means all of: diff --git a/docs/EDGE_CLI.md b/docs/EDGE_CLI.md new file mode 100644 index 00000000000..de427311d75 --- /dev/null +++ b/docs/EDGE_CLI.md @@ -0,0 +1,499 @@ +# Edge CLI + +A command-line interface for the Edge platform. Useful for account management, +wallet operations, debugging, and scripting against edge-core-js. + +The CLI is a **thin one-shot client**. A long-lived **engine daemon** owns the +`EdgeContext`, keeps logged-in accounts alive across invocations, and exposes a +JSON REST API over a Unix domain socket (TCP is optional). + +For the full surface — every command, its REST call, and the `edge-core-js` +call behind it — see the generated reference at +[docs/api/dist/index.html](./api/dist/index.html), built from `docs/api/`. + +## Overview + +| Piece | Role | +|-------|------| +| `edge-engine` | Long-lived daemon. Owns one `EdgeContext` and N `EdgeAccount`s keyed by `sessionId`. Serves HTTP. | +| `edge-cli` | One-shot client. Parses argv, auto-spawns the engine if needed, talks over the Unix socket, prints results. | + +By default the client uses only the Unix socket at +`~/.edge-cli/run//engine.sock`. Enable loopback TCP with +`--tcp=9008` on the engine (useful for `curl` / scripts). That port is +authenticated with a token from the run file — see +[Sessions](#sessions). + +The CLI keeps state under two roots: + +| Path | Holds | +|------|-------| +| `~/.config/edge-cli/` | `edge-cli.conf`, and the account data directory `--directory` defaults to. The data directory is created `0700`: it holds the login stashes | +| `~/.edge-cli/run//` | `engine.sock`, `engine.json`, `session.json` — per profile; directory `0700`, files `0600` | +| `~/.edge-cli/logs/` | `engine-.log` | +| `~/.edge-cli/keys.json` | API keys, after `./keys.json` | + +Account repos therefore live under `~/.config/edge-cli`, not `~/.edge-cli`. +Pass `--directory` to put them somewhere else; it is part of the profile hash, +so a different directory is a different engine. + +## Running + +**Development (from source):** + +```bash +npm run cli -- help # One-shot via client (auto-spawns engine) +npm run cli -- login-with-password --username=u --password=p # sessionId is persisted +npm run cli -- balance-map --wallet-id= # Reuses the engine + session + +npm run engine # Start the engine alone +npm run engine -- -t # Engine against tester servers +npm run engine -- --tcp=9008 # Also listen on 127.0.0.1:9008 +``` + +**Built artifact:** + +```bash +npm run build:cli # → lib/edgeCli.js + lib/edgeEngine.js +node lib/edgeCli.js help +node lib/edgeEngine.js -t --tcp=9008 +``` + +**Published (npm):** not yet. `edge-react-gui` is `private` with no `bin` +entry, so there is nothing for `npx` to fetch and no `edge-cli` on a `PATH`. +Until a package is published, run the built artifact directly as above. The +`edge-cli` name used throughout this document is the command the built +`lib/edgeCli.js` presents as; it is not an npm package name. + +**Interactive prompt:** run `edge-cli` with no command and it reads commands +from stdin instead of exiting, with tab completion over the command list. The +engine, session and flags are the same as one-shot mode — `edge-cli -t` with no +command opens a prompt already pointed at the tester servers. `help` lists the +commands, and EOF (Ctrl-D) leaves. + +### Engine / client flags + +| Flag | Who | Description | +|------|-----|-------------| +| `-t, --test` | both | Use the six `-tester` servers — see below | +| `--fake` | both | Emulate the login, info and sync servers in-process — no network, no API key. Its own engine profile, so it never shares a socket with a real one | +| `-d, --directory` | both | Working directory for local Edge data | +| `-a, --app-id` | both | Application ID | +| `-k, --api-key` | both | Override API key from `keys.json` — also turns off the keys.json secret and the native HMAC signer. The client forwards it to the engine in the environment, not on the command line. An `apiKey` in the config file supplies the key *without* that override | +| `--locale ` | both | Language tag (BCP 47 or POSIX). Also `EDGE_CLI_LOCALE` or `locale` in the config file | +| `--tcp=` | both | Bind TCP on `127.0.0.1`, token-authenticated — off by default; bare `--tcp` and `--tcp=` are both errors, and `--tcp=0` picks an ephemeral port. On the client it is forwarded to the engine it spawns | +| `--idle-timeout=` | engine | Self-shutdown once nothing holds the engine open — default `300`, `0` means never, and a blank value is an error | +| `--no-spawn` | client | Do not auto-start the engine; fail if none is running | +| `--timeout=` | client | Per-request deadline (default `120`) — on expiry the client gives up while the engine runs the request to completion, so raise it for a whole-wallet `get-transactions`, `wait-for-all-wallets` or `resync-blockchain` | +| `--session ` | client | Override the persisted `sessionId` | +| `--solve-captcha` | client | On `CHALLENGE_REQUIRED`, auto-solve ALTCHA PoW and retry | +| `-c, --config ` | both | Configuration file. Forwarded to the engine the client spawns, so one file decides both halves | +| `--tcp-host=` | engine | TCP bind host, loopback only (default `127.0.0.1`) — a non-loopback address is a usage error, because the port would expose `spend` and `get-raw-private-key` to the network | +| `-u, --username` | client | Legacy one-shot login helper | +| `-p, --password` | client | Legacy one-shot login helper | +| `-h, --help` | both | Show options | + +API keys load from `./keys.json`, then `~/.edge-cli/keys.json` +(`edgeApiKey`, `edgeApiSecret`, `pluginApiKeys`). + +When the native Edge API HMAC signer is available, the engine prefers it over +`keys.json` secrets for **both** `edge-core-js` and `GET /v1/getKeys` on the +info server. Plugin secrets (including Monero LWS `edgeApiKey`) come from that +fetch and overlay local `pluginApiKeys`. Set `EDGE_CLI_FORCE_KEYS_JSON=1` +(or pass `-k`) to force the JSON key/secret pair instead — useful for tester +embeds and debugging. `-t` signs getKeys against `info-tester.edge.app`. + +Locale: `--locale`, then `locale` in `edge-cli.conf`, then `EDGE_CLI_LOCALE`, +then `LC_ALL` / `LC_MESSAGES` / `LANG`, then `Intl`, then `en-US`. The tag +selects the language tables the engine's responses are built from. It also +sets `decimalSeparator` and `groupingSeparator`, which `GET /engine/status` +reports — but nothing on either CLI entry's import graph formats a number +through them, so today they are reported for a caller's benefit rather than +applied to anything the CLI prints. An already-running engine keeps its +locale; the client warns on mismatch and continues. + +## Tester servers + +**Always use `-t` / `--test` for testing. Never hit production in tests.** + +`-t` points the engine at these six hosts (the only `*-tester.edge.app` +names that resolve): + +| Host | `EdgeContextOptions` field | +|------|----------------------------| +| `https://login-tester.edge.app` | `loginServer` | +| `https://info-tester.edge.app` | `infoServer` | +| `https://sync-tester-us1.edge.app` | `syncServer` (array) | +| `https://sync-tester-us2.edge.app` | `syncServer` | +| `https://sync-tester-us3.edge.app` | `syncServer` | +| `https://change-tester.edge.app` | `changeServer` | + +```bash +npm run cli -- -t --solve-captcha create-account --username=alice --password='pass' --pin=1234 +npm run cli -- -t login-with-password --username=alice --password='pass' +``` + +Confirm with `edge-cli engine-config` — every server URL should be a +`*-tester.edge.app` host. `testMode` being true is necessary but not +sufficient: it means "not production", and `--fake` reports true while pointed +at `fake://login`, so read the server list to tell the two apart. + +## Architecture + +```mermaid +flowchart LR + cli["edge-cli (one-shot)"] -->|"HTTP / unix socket"| engine + script["scripts / curl"] -->|"HTTP / TCP (opt-in --tcp=9008)"| engine + subgraph engine [edge-engine daemon] + router[Router] --> sessions[SessionStore] + sessions --> account1["EdgeAccount (sess_A)"] + sessions --> account2["EdgeAccount (sess_B)"] + router --> context["EdgeContext (single)"] + end + context --> core[edge-core-js + currency plugins] +``` + +ASCII equivalent: + +``` +edge-cli ──HTTP──► engine.sock ──► edge-engine + │ + ├─ EdgeContext (one) + └─ accounts by sessionId + (sess_… → EdgeAccount) +``` + +A *profile* is a hash of `{ appId, directory, testMode, loginServer }`. +Distinct profiles get distinct run directories, so a tester engine and a +production engine can coexist. `directory` is canonicalised first — resolved +to an absolute path and through any symlink — so one data directory is one +profile however the path is spelled, and a trailing slash or a relative `-d` +cannot give it a second engine. + +## Discovery + +Under `~/.edge-cli/run//` (files mode `0600`): + +| File | Purpose | +|------|---------| +| `engine.json` | Discovery / lock: pid, apiVersion, socketPath, tcpPort, appId, testMode, startedAt | +| `engine.sock` | Unix domain socket (always on) | +| `session.json` | Last `sessionId` written by the client. Removed when the engine stops, since a session cannot outlive it | +| `engine-startup.log` | The spawned engine's stdout and stderr, so a startup that dies before the socket exists (bad `keys.json`, a plugin that will not load) leaves a record rather than a spawn timeout. Kept when a stale lock is cleared, because the replacement engine is already writing to it | + +A clean shutdown removes all four and the profile directory with them. + +Example `engine.json`: + +```json +{ + "pid": 40123, + "apiVersion": "1.0.0", + "socketPath": "/Users/you/.edge-cli/run/8f3a.../engine.sock", + "tcpPort": null, + "appId": "", + "testMode": true, + "startedAt": "2026-08-06T04:55:00.000Z" +} +``` + +Client flow: the profile is a pure hash of four argv-derived values, so the +client needs nothing on disk to know which socket to use. It sends the request +straight at that socket; only on ENOENT or ECONNREFUSED does it spawn the +engine (unless `--no-spawn`), and `ensureEngine` pings `/engine/status` first +in case one is already up, then polls readiness for up to 30 s and retries the +request once. + +```bash +# Manual status check over the socket +curl --unix-socket ~/.edge-cli/run//engine.sock \ + http://localhost/engine/status +``` + +## Sessions + +Successful login returns an opaque `sessionId` (`sess_` + base58 of 16 random +bytes). Account-scoped REST paths look like: + +``` +/account/{sessionId}/wallet/balance-map?walletId= +``` + +A `sessionId` **is** the credential: every account-scoped route checks it and +nothing else, so holding one means `get-pin`, `get-raw-private-key`, +`get-login-key` and `spend`. Core authenticates the login itself via password +/ PIN / key / recovery; `sessionId` scopes everything after that. + +The Unix socket therefore needs no transport auth of its own — it is `0600` +inside a `0700` directory, so the operating system is the check. **The +loopback TCP listener does**, because any process on the host can reach +`127.0.0.1` whatever user it runs as, and so can a web page the user happens +to be looking at. With `--tcp` the engine: + +- mints a bearer token at startup and writes it to the `0600` run file as + `tcpToken`, and requires it in an `X-Edge-Token` header; +- refuses any request carrying an `Origin` header, and any request whose + `Host` is not the address it bound — which is what stops a page reaching it + by rebinding a name onto the port; +- refuses to bind anything but a loopback address. + +```bash +edge-cli engine-status # starts an engine +TOKEN=$(jq -r .tcpToken ~/.edge-cli/run//engine.json) +curl -sH "X-Edge-Token: $TOKEN" http://127.0.0.1:9008/engine/status +``` + +A missing or wrong token is `401 UNAUTHORIZED`; a bad `Origin` or `Host` is +`403 FORBIDDEN`. `engine-sessions` truncates every `sessionId` it reports, so +the listing is a diagnostic rather than a way to collect credentials. + +The client persists the latest id in `session.json` so commands chain without +re-typing. Override with `--session ` or `EDGE_CLI_SESSION`. + +**Auto-logout** mirrors the GUI: the engine reads `autoLogoutTimeInSeconds` +from the account’s synced `Settings.json` (default `3600`, `0` = disabled) and +logs the account out after that much idle time since the last REST call that +touched the session. `edge-cli touch` is an explicit keepalive. + +**Engine idle shutdown:** after ~5 minutes with nothing holding it open, the +engine closes the context, unlinks the socket / run file, and exits. Four +things hold it: a logged-in session, a live subscription, a request being +served, and a pending edge login, whose handle belongs to no session and whose +TTL is the same 5 minutes. Configure with `--idle-timeout` (`0` = never). A live `subscribe` holds +it open — see [Subscribing to events](#subscribing-to-events). + +```bash +edge-cli -t login-with-password --username=alice --password='pass' # stores sessionId +edge-cli currency-wallets # uses persisted session +edge-cli engine-sessions +edge-cli touch +edge-cli logout +``` + +## CAPTCHA + +`usernameAvailable`, `createAccount`, and `loginWithPassword` can raise a +login-server CAPTCHA. The engine does **not** solve it. It returns: + +```json +{ + "error": { + "code": "CHALLENGE_REQUIRED", + "status": 403, + "message": "Login requires a CAPTCHA challenge challengeId=YUXCPENDRSDHMMA7 challengeUri=https://login-tester.edge.app/captcha/YUXCPENDRSDHMMA7 Retry the same request with body/query challengeId after solving, or use CLI --solve-captcha.", + "details": { + "challengeId": "YUXCPENDRSDHMMA7", + "challengeUri": "https://login-tester.edge.app/captcha/YUXCPENDRSDHMMA7" + } + } +} +``` + +Options: + +1. **CLI helper** — `--solve-captcha` on any login command headlessly + solves ALTCHA PoW at `challengeUri` and retries with `challengeId`. +2. **Manual** — open the URI in a browser, then re-run the command with + `--challenge-id ` (or pass `challengeId` in the REST body). +3. **Prefetch** — `edge-cli fetch-challenge` → `POST /fetch-challenge`. + +Automated tests use the same ALTCHA solver (see `src/cli/client/solveCaptcha.ts`). + +## Edge login (QR / barcode) + +`edge-cli request-edge-login` requests a pending Edge login and prints JSON the +approving device can use. By default it then **blocks**, polling every 2 seconds +for up to 5 minutes, and stores the session as soon as the login is approved. +Pass `--no-wait` to print the JSON and exit immediately, which is what a script +that drives its own polling wants: + +```json +{ + "pendingId": "pending_7Qk3mVJ2xR4t", + "lobbyId": "HbC9mVJ2xR4tN8pL", + "uri": "edge://edge/HbC9mVJ2xR4tN8pL", + "state": "pending" +} +``` + +Approve from another logged-in Edge device (Scan QR), or paste `uri` / +`lobbyId` via **Scan QR → Enter** (useful with Maestro on the iOS simulator). + +After `--no-wait`, poll it yourself with `poll-edge-login ` (or +`GET /pending-edge-login/{pendingId}`) until `state` is `done` — it then carries +the session — or `error`. `fetch-lobby ` reads the lobby without +waiting, and `cancel-request ` abandons it. + +## Command shape + +Commands are not listed here. The full reference — every command paired with +the REST call it makes and the `edge-core-js` call behind it, with request and +response types and an example — is generated from the route declarations: + +**[docs/api/dist/index.html](./api/dist/index.html)** + +```bash +npm run docs:api # rebuild it +npm run docs:api:gates # check it still matches src/cli +``` + +Every command follows one shape: + +``` +edge-cli [global flags] [--flag=value ...] +``` + +| Form | Example | +|------|---------| +| Preferred | `--wallet-id=7o7i6` | +| Also accepted | `--wallet-id 7o7i6` | +| Boolean | `--paused` means true; `--paused=false` turns it off. A required one must be written out: `--paused=true` | +| Repeatable | `--answer=rex --answer=oak` | +| Lists | comma-separated, no spaces: `--export-format=csv,qbo` | +| JSON | single-quoted: `--spend-info='{"tokenId":null}'` | + +Arguments are named. A command takes a bare positional only where the value is +a base58 identifier the engine issued — an object handle, a pending login — +because only those are safe as a URL path segment. A wallet id is base64 and a +username is free text, so both are flags. `edge-cli help ` prints the +exact usage for any of them, and that text is generated from the same source +as the reference. + +For the native asset, omit `--token-id` rather than passing the literal +`null`. An empty `--name=` is a usage error, as are unknown flags and extra +positionals. + +### Subscribing to events + +`edge-cli subscribe` holds a Server-Sent Events stream open and prints one JSON +object per line until you interrupt it. It runs concurrently with ordinary +one-shot commands, so a subscriber in one terminal watches what another +terminal does: + +```bash +# terminal 1 +edge-cli subscribe --type=session.created --type=session.expired + +# terminal 2 +edge-cli -t login-with-password --username=alice --password='pass' +edge-cli logout +``` + +A live subscription keeps the **engine** alive past its idle timeout — the +stream would otherwise die under the subscriber. It does **not** keep an +**account** logged in: the auto-logout timer still fires on schedule. + +Scope comes from the query string. `subscribe` opens an **unscoped** stream, +which carries context-level events and survives for as long as the engine +does — so a subscriber that logged in, was auto-logged-out and logged in again +keeps the same stream, and `subscription.closed` arrives only when the engine +stops. A stream opened with `?sessionId=` is account-scoped and that session's +logout closes it, with `?walletId=` narrowing it further; `?type=` repeated +filters it in the engine, so an unwanted event never crosses the socket. + +`subscribe` exits `0` on Ctrl-C and `7` when the engine ends the stream, or +for any close reason it does not recognise. A Ctrl-C during a cold start has +to wait for the engine to finish spawning before the stream can be closed +cleanly; a second Ctrl-C in that window exits `130` immediately instead. + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Generic failure | +| `2` | Usage / bad argv | +| `3` | Auth / session | +| `4` | Not found | +| `5` | Validation / funds | +| `6` | Network | +| `7` | Engine unavailable | + +Codes `3` to `6` are assigned from an explicit list of error codes, published +per code in the generated reference. An error code with no entry in that list +— and any non-`ApiClientError` failure — exits `1`, so `1` means "failed, with +no more specific mapping" rather than "unknown error". + +## Source layout + +``` +src/cli/ + engine/ + index.ts # Daemon entry, argv, signals + makeCoreContext.ts # Plugin registration + makeEdgeContext + server.ts # HTTP handler; unix (+ optional TCP) listeners + router.ts # Method + path dispatch + route.ts # route() — the declaration every doc and command is built from + doc.ts # doc() — prose attached to a cleaner + schemas.ts # Query coercions and shared response shapes + objectHandles.ts # Handles for core values that cannot cross JSON + sessions.ts # SessionStore + auto-logout ticker + idleShutdown.ts # Idle self-shutdown + discovery.ts # Profile hash, run-file, socket paths + errors.ts # EngineError + core → HTTP mapping + errorGroups.ts # Error codes that recur across routes + apiVersion.ts # The protocol version, for every surface that shows it + transportAuth.ts # Token, Host and Origin checks for the TCP listener + tcpPort.ts # --tcp parsing, shared by both entries + cliHome.ts # ~/.edge-cli and the paths under it + readJsonConfig.ts # One read-parse-clean for the config files + sweepTicker.ts # The periodic sweep both stores run + json.ts # Body parse / Uint8Array·Map codec + internal.ts # `$internalStuff` access for the admin routes + resolve.ts # walletId prefix, tokenId parsing + events.ts # SSE hub + logger.ts # Engine log file + cliConfig.ts # edge-cli.conf + default directory + keysConfig.ts # keys.json search path + appConfig.ts # appId / app config + fetchPluginKeys.ts # Remote plugin keys over the signed infoRollup + nodeApiSigner.ts # Node HMAC signer for the Edge API + testerServers.ts # The six -tester hosts + routes/ # status, login, account, wallets, … + client/ + apiClient.ts # HTTP over socketPath or TCP + spawnEngine.ts # Auto-spawn + readiness poll + sessionFile.ts # Persisted sessionId + solveCaptcha.ts # Headless ALTCHA solver for --solve-captcha + output.ts # JSON / NDJSON output + exit codes + exitCodes.ts # Error code → exit code, shared with the reference + commands/ # Argv → apiClient → output (no core imports) + command.ts # command() registry + commandArgs.ts # Per-command flag parsing + parseArgs.ts # Client/engine argv before the command name + flagTable.ts # Every global flag, once; both help texts render it + bootNodeLocale.ts # Locale detection, before anything reads a string + bootEngineLocale.ts # Applies it; engine only, so the client ships no tables + generatedSchemas.ts # Cleaners for the files scripts/build* generate + index.ts # One-shot and interactive front-end +``` + +Shared, outside `src/cli/`: `src/util/predicates.ts` holds the small +predicates both halves use, because `src/util/exportTxInfo.ts` needs one and +that module is reached from the app — the React Native bundle must not import +out of the daemon's directory. + +Every module in `src/cli/engine/` is listed above; `scripts/cliNodeSafeSmoke.js` +is what keeps the list honest about what loads under plain Node. + +## Tests + +| Script | What it runs | +|--------|--------------| +| `npm run test:cli:offline` | `testCliFake` + `testCliSubscribe` against the sources, through `sucrase/register`. No network, no Edge API key. | +| `npm run test:cli:offline:built` | The same suites against `lib/edgeCli.js`, the bundle `build:cli` produces — which is how the CLI is run until a package is published. Part of `verify`. | +| `npm run test:cli:node-safe` | Loads every CLI module under plain Node, so a `react-native` import at module scope fails here. | +| `npm run test:cli:network` | One-shot, CAPTCHA and Edge-login suites. Needs the network and an Edge API key. | +| `npm run docs:api:gates` | The five documentation gates. | + +Three different transforms produce a working CLI — `sucrase` for the suites, +`@react-native/babel-preset` for jest, `@babel/preset-env` for the bundle — so +a defect can exist in only one of them. `EDGE_CLI_BIN=` points either +offline suite at any built CLI. + +## REST API + +Full method/path/body/error documentation is generated: +**[docs/api/dist/index.html](./api/dist/index.html)**, with an OpenAPI 3.1 +document beside it at `docs/api/dist/openapi.json`. The source of truth is +`docs/api/`; see [docs/api/README.md](./api/README.md). diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 00000000000..74faee60adf --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,169 @@ +# Edge CLI API docs + +The `edge-cli` command line and the `edge-engine` REST API, declared once and +rendered together. Each call is a single `route({…})` in +`src/cli/engine/routes/`, holding both forms, so the CLI usage and the HTTP +request cannot drift apart — from each other or from the code. + +```bash +npm run docs:api # rebuild the command table, help text and dist/ +npm run docs:api:gates # the five checks Travis and precommit run +``` + +Open `docs/api/dist/index.html` in a browser. The command line comes first in +every entry, the REST call second, and each states the `edge-core-js` call it +fronts. + +**`dist/` is committed on purpose** so the reference can be read on GitHub and +linked to without a build step. `npm run docs:api` is idempotent: it rewrites +the generated files only when they change, and `docs:api:check` fails if a +route edit landed without them being rebuilt. + +## Naming + +Routes are named after the core call they front, kebab-cased, and the command +matches: `context.forgetAccount` becomes `POST /forget-account` and +`forget-account`. Parameters keep core's names. + +A path parameter is a base58 identifier, and nothing else — `sessionId`, +`objectId`, `pendingId`, `lobbyId`, `syncKey`. Base58 has no `/`, `?` or `#`, +so it survives a URL as written. A base64 wallet id or a free-text username +does not, so those are named arguments: the query for `GET`, the body for +`POST`. That is why `balance-map` is +`GET /account/{sessionId}/wallet/balance-map?walletId=…` rather than putting +the wallet id in the path. Where a path parameter is allowed it comes last, in +the order the command reads. Collection segments are singular, since each call +acts on one. Only `GET` and `POST` are used, since core has no HTTP verbs, and +a core method returning `void` answers `204`. + +A call with no core equivalent sets `core: null` and explains itself in a +`@coreNote`; the verifier enforces that. + +## Why generated, not hand-written + +A hand-maintained reference drifted badly: response shapes no route returned, +status codes off by a category, body fields under the wrong name, and a +documented `confirm=true` guard on account deletion the engine never +implemented. None of that is visible by reading either the doc or the code +alone — only by diffing them. + +So the declaration is the documentation. `scripts/extractRoutes.ts` reads every +`route(…)` with the TypeScript checker: the JSDoc above it is the prose, and +its `query`, `body` and `returns` cleaners are the shapes, resolved to the +validator's own types. Everything downstream — the CLI's command table, its +`help` text, the HTML reference and the OpenAPI document — is generated from +that one source. + +## The five gates + +`npm run docs:api:gates`, also run by `precommit`: + +| Gate | Checks | +| --- | --- | +| `docs:api:check` | the generated files are current — rebuild them and nothing changes | +| `docs:api:verify` | the surface matches: no route without the command it claims, no command nobody declares, no flag on one side missing from the other, no `core` naming a member `edge-core-js` does not have | +| `docs:api:contracts` | the contract holds: every field a caller can send is described, nothing described has gone away, and no handler reads a field its cleaner would strip | +| `docs:api:core` | each route's request matches the real signature of the core call it fronts, or records why it differs in `coreExtra` | +| `docs:api:coverage` | every command is exercised by an automated test, or is listed with a reason | + +`docs:api:core` exists because checking the core member by name is not enough: +that is how `currency-wallets` came to carry a `waitForAll` parameter +`account.currencyWallets` does not have — it is a property, and waiting is a +separate method. + +## Layout + +``` +docs/api/ + README.md this file + groups.ts section titles and order, keyed by route-file basename + shared.ts the error catalogue; re-exports the shared error + groups and the exit-code table from runtime code + dist/ generated — do not edit +scripts/ + extractRoutes.ts reads the route declarations + cliUsage.ts renders a usage line, and decides which fields + need a JSON argument + writeIfChanged.ts writes an output only when its bytes differ + buildCliCommands.ts -> src/cli/generated/commands.json + buildCliHelp.ts -> src/cli/generated/helpDocs.json + buildApiDocs.ts -> dist/index.html and dist/openapi.json + verifyApiDocs.ts surface drift + checkRouteContracts.ts contract drift + checkCoreAlignment.ts core signature drift + checkCliCoverage.ts untested commands +``` + +There is no separate doc file per route: the route file *is* the doc file. +`groups.ts` only decides section titles and render order. + +## Adding an endpoint + +Declare the route, and it documents itself: + +```ts +/** + * Balances for every asset in the wallet. + * + * @note On the CLI, omit `--token-id` for the native asset rather than passing + * the literal `null`. + * @coreNote Rendered as an array, with currencyCode and displayAmount added + * from the wallet's denominations. + */ +export const balanceMap = route({ + core: 'wallet.balanceMap', // or null, with a @coreNote saying why + method: 'GET', + path: '/account/{sessionId}/wallet/balance-map', + cli: { command: 'balance-map' }, + query: asObject({ walletId: asWalletId }).withRest, + returns: asObject({ + balances: doc( + asArray(asBalance), + 'One entry per asset the wallet holds, native coin first.' + ) + }), + errors: WALLET_ERRORS, + + handler(ctx) { + /* … */ + } +}) +``` + +Then `npm run docs:api && npm run docs:api:gates`. + +Conventions worth keeping: + +- Wrap a cleaner in `doc(…)` to describe a field. `checkRouteContracts` fails a + response field with no prose, so the reference cannot ship a bare type. +- Reuse the shared error lists from `src/cli/engine/errorGroups.ts` + (`SESSION_ERRORS`, `WALLET_ERRORS`, `HANDLE_ERRORS`) rather than restating + them. They live in runtime code so routes can import them and `shared.ts` + can re-export them for the reference; a route importing from `docs/` would + have the dependency backwards. Take error codes from the catalogue in + `shared.ts` — a code not in it fails `verify`. +- Put anything a caller would get wrong from the type alone in a `@note`: + surprising defaults, fields that look symmetric but are not, calls that write + when they look like reads. +- Two commands may share a route (`spend` / `spend-max`, via `preset`), and a + route may declare `cli: { custom: true }` when its command needs code of its + own. Both are fine; declare every binding on the route it calls. + +## Runtime validation + +The engine validates its own responses. `checkResponse` in +`src/cli/engine/route.ts` runs each response through the route's `returns` +cleaner on every request and **discards the cleaned value** — response cleaners +strip unknown keys, so returning it would quietly delete fields the engine +means to send. The check reports drift; it never reshapes anything. + +`EDGE_CLI_CHECK_RESPONSES` picks what a mismatch costs: + +| Value | Behaviour | +| --- | --- | +| unset, or `warn` | log `Response type mismatch` and answer normally (the default) | +| `strict`, or `1` | fail the request with `500 INTERNAL_ERROR` | +| `off`, or `0` | skip the check | + +So the documented shape is the validated shape, and a drifting response shows +up in the engine log rather than silently reaching a caller. diff --git a/docs/api/dist/index.html b/docs/api/dist/index.html new file mode 100644 index 00000000000..135ddf76e8c --- /dev/null +++ b/docs/api/dist/index.html @@ -0,0 +1,8499 @@ + + + + +Edge CLI API + + +
+ +
+

Overview

+

Every entry is one API call shown twice: as an edge-cli command, then as the JSON REST request that command sends. Both are generated from a single declaration in src/cli/engine/routes/, so the two forms cannot drift apart.

+

Routes are named after the edge-core-js call they front, kebab-cased: context.forgetAccount becomes POST /forget-account, and the command is forget-account. Parameters carry core's own names. Every entry states its core call, or says why there is none. Only GET and POST appear — core has no HTTP verbs, so reads are GET and everything else is POST.

+

The edge-cli client is a thin one-shot process. A long-lived edge-engine daemon owns the EdgeContext and every logged-in account, serving this API over a Unix socket at ~/.edge-cli/run/<profile>/engine.sock, plus loopback TCP when started with --tcp=9008.

+

There is no transport authentication. The socket is owner-only (0600) and TCP is loopback, so anything that can reach the engine can act as every logged-in account.

+

+Ephemeral object handles. In edge-core-js a method-bearing value is identified by object reference — you call wallet.signTx(tx) on the very tx that makeSpend returned. That does not survive HTTP, so the engine parks such values under an objectId with a 5 minute TTL and later steps name the id. Reads do not extend the TTL; only a step that updates the value does. Finishing a workflow, or POST …/objects/{objectId}/delete, releases the handle early. Expired handles return 410 OBJECT_EXPIRED.

+

Serialization. Uint8Array becomes base64, Date becomes an ISO-8601 string, Map becomes an object, amounts are always decimal strings, and EdgeTokenId is JSON null for a native asset.

+

Testing. Always pass -t / --test to point at the *-tester.edge.app servers.

+
+

Engine

+

The edge-engine daemon itself. None of these have an edge-core-js equivalent — they describe the process — and none need a session.

+
+

Lifecycle

+

Lifecycle and configuration of the edge-engine daemon. None of these have an edge-core-js equivalent — they describe the daemon itself — and none need a session.

+
+
+
+

Engine liveness and summary.

+
engine-statussrc/cli/engine/routes/status.ts
+
+

coreEngine lifecycle; the daemon is not part of the core API.

+

The readiness probe the client polls after auto-spawning the engine.

+
+
+

Command line

+
engine-status
+ + + +
+

REST

+

GET/engine/status

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/engine/status'
+
+
+

Response 200

+

idleShutdownAt is null while a session or a subscription holds the engine open, and tcpPort is null unless started with --tcp.

+
+
Response body
+
{
+  pid: number
+  apiVersion: string
+  uptimeSeconds: number
+  sessionCount: number
+  testMode: boolean
+  idleShutdownAt: string | null
+  tcpPort: number | null
+  socketPath: string
+  rateCachedCount: number
+  locale: string
+  localeMatched: boolean
+  decimalSeparator: string
+  groupingSeparator: string
+}
+
Example
{
+  "pid": 0,
+  "apiVersion": "string",
+  "uptimeSeconds": 1,
+  "sessionCount": 1,
+  "testMode": true,
+  "idleShutdownAt": "2026-09-02T16:35:00.000Z",
+  "tcpPort": 0,
+  "socketPath": "string",
+  "rateCachedCount": 1,
+  "locale": "string",
+  "localeMatched": true,
+  "decimalSeparator": "string",
+  "groupingSeparator": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
pidnumberThe daemon process, for kill when it will not stop.
apiVersionstringThe API this engine speaks. A client refusing to talk to an older engine checks this.
uptimeSecondsnumberHow long the daemon has been running.
sessionCountnumberLogged-in accounts held open right now.
testModebooleanTrue when the engine is not pointed at production: the tester fleet, or the in-process fake world under --fake.
idleShutdownAtstring | nullWhen the engine will exit for want of work. Null while anything is holding it open — a logged-in session, a live subscription, a request being served, or a pending edge login, whose handle belongs to no session — and null when the timeout is disabled.
tcpPortnumber | nullThe loopback port, null unless started with --tcp.
socketPathstringUnix socket the CLI connects to.
rateCachedCountnumberExchange rates held in the engine’s process cache. It is bounded and cleared when the last session goes away, and this is how an operator sees it.
localestringLanguage tag the engine resolved at boot.
localeMatchedbooleanWhether a translation table for that tag was actually found. False means the tag was accepted but the engine is answering in English, which is otherwise indistinguishable from a build that has the language.
decimalSeparatorstringDecimal mark for that locale.
groupingSeparatorstringThousands mark for that locale.
+
+
Errors

503ENGINE_SHUTTING_DOWN

+
+ +
+
+

Configured context options.

+
engine-configsrc/cli/engine/routes/status.ts
+
+

coreReflects the EdgeContextOptions the engine supplied at startup.

+

What the engine passed to makeEdgeContext. Contains no secrets. Use it to assert tester hosts before a test run.

+
+
+

Command line

+
engine-config
+ + + +
+

REST

+

GET/engine/config

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/engine/config'
+
+
+

Response 200

+
+
Response body
+
{
+  appId: string
+  testMode: boolean
+  directory: string
+  servers: {
+    [keys: string]: string | string[]
+  }
+  plugins: string[]
+}
+
Example
{
+  "appId": "FS8xJ2kQ…",
+  "testMode": true,
+  "directory": "string",
+  "servers": {},
+  "plugins": [
+    "string"
+  ]
+}
+ + + + + + + + + + + + + + + + + + + + + + + + +
appIdstringApplication ID the engine was started with.
testModebooleanTrue when the engine is not pointed at production: the tester fleet, or the in-process fake world under --fake. Read servers to tell those apart.
directorystringWorking directory holding the core data.
servers{ [keys: string]: string | string[]; }The URLs this engine talks to, keyed by role. syncServer is a list, since core rotates across the sync fleet.
pluginsstring[]Plugin IDs the engine loaded, sorted.
+
+ +
+

Notes

  • Outside -t / --test, servers is an empty object — core is using its built-in production defaults, so there is nothing to echo back.
+
+
+

Stop the engine.

+
engine-stopsrc/cli/engine/routes/status.ts
+
+

coreEngine lifecycle. Internally calls context.close().

+

Logs out every session, closes the context, unlinks the socket and run-file, then exits. The engine answers before it starts tearing down, so a response is not proof the process is gone.

+
+
+

Command line

+
engine-stop
+ + + +
+

REST

+

POST/engine/stop

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/engine/stop'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+}
+
Example
{
+  "ok": true
+}
+ + + + +
okbooleanAlways true; a failure arrives as an error envelope.
+
+ +
+

Notes

  • 503 ENGINE_SHUTTING_DOWN reaches requests that arrive after teardown starts, not ones already in flight: handleRequest tests the flag at the top only. A request already past that point is waited for — shutdown drains in-flight work before logging out — so it gets its real response. This route is exempt: a second stop is answered ok, because stopping an engine that is already stopping has succeeded.
+

Event stream

+

A Server-Sent Events feed of engine activity, served outside the router because the response never ends.

+
+
+
+

Subscribe to engine events.

+
subscribesrc/cli/engine/routes/events.ts
+
+

coreEngine-side fan-out; core.log frames carry core's onLog output.

+

Holds a Server-Sent Events stream open until the caller disconnects or the engine closes it. Runs concurrently with one-shot calls, so a subscriber in one terminal watches what another terminal does.

+

A live subscription holds the engine open past its idle timeout. It does not hold an account logged in: the auto-logout timer still fires, and closes any subscription scoped to that account or one of its wallets. Context-scoped subscriptions survive, because the context outlives every account.

+

Scope comes from the query string. With no sessionId the stream is context-scoped and nothing but the engine stopping ends it, which is what the subscribe command asks for. With sessionId it is account-scoped: a logout closes it, and the session events of other accounts are filtered out. walletId narrows it further, for whenever a wallet-scoped event exists — no event carries a wallet scope today, so on its own it changes only which events close the stream.

+
+
+

Command line

+
subscribe [--session-id=<sessionId>] [--wallet-id=<walletId>] [--type=<value>]
+ +
Client-only flags
--typeoptionalOnly these event types. Applied by the engine, so an unwanted type never crosses the socket; the client filters again for the types the engine sends regardless, like subscription.closed.
+

Prints newline-delimited JSON and runs until interrupted. Exits 0 on SIGINT and 7 when the engine ends the stream. subscribe opens an unscoped stream, which only the engine stopping ends; a stream opened with sessionId is closed by that session logging out.

+
+
+

REST

+

GET/engine/events

+ +
+
Query
+
{
+  sessionId?: string
+  walletId?: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
sessionIdstring optionalScope the stream to one account, so a logout closes it. Omitted, the stream is context-scoped and only the engine stopping ends it.
walletIdstring optionalWith sessionId, narrow the stream to one wallet. No event carries a wallet scope yet, so today this only narrows which events can close the stream. Ignored on its own.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/engine/events'
+
+
+

Response 200

+

One frame per event, as event: then data: lines.

+
+
Response body
+
{
+  type: string
+  data: unknown
+}
+
Example
{
+  "type": "string",
+  "data": {}
+}
+ + + + + + + + + +
typestringThe event name.
dataunknownPayload, shaped by the event type.
+
+ +
+

Notes

  • Frame types: core.log, session.created, session.expired, engine.shutdown, and subscription.closed when the engine ends it.
  • The subscribe command prints one further frame of its own once the stream is over, subscription.ended, carrying the close reason. It comes from the client, so --type does not filter it.
  • sessionId in event payloads is truncated to its first 10 characters.
  • A client more than 1 MiB behind is disconnected rather than buffered.
  • Served directly by the HTTP handler rather than through the router, because the response never ends.
+

Context

+

Calls on the shared EdgeContext: device state, username queries, and every way of logging in. None of them need a session, because a session is what they produce.

+
+

Device and usernames

+

Calls on the shared EdgeContext: local device state and login-server queries that do not need a session.

+
+
+
+

List local users on this device.

+
local-userssrc/cli/engine/routes/context.ts
+
+

corecontext.localUsers

+ +
+

Command line

+
local-users
+ + + +
+

REST

+

GET/local-users

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/local-users'
+
+
+

Response 200

+

Everything context.localUsers reports, including which login methods each user has enabled on this device.

+
+
Response body
+
{
+  localUsers: unknown[]
+}
+
Example
{
+  "localUsers": [
+    {}
+  ]
+}
+ + + + +
localUsersunknown[]EdgeUserInfo[]: one entry per account cached on this device.
+
+ +
+ +
+
+

Forget an account on this device.

+
forget-accountsrc/cli/engine/routes/context.ts
+
+

corecontext.forgetAccount

+

Removes locally cached credentials. The remote account is untouched.

+
+
+

Command line

+
forget-account --root-login-id=<rootLoginId>
+ + + +
+

REST

+

POST/forget-account

+ + + +
+
Request body
+
{
+  rootLoginId: string
+}
+
Example
{
+  "rootLoginId": "FS8xJ2kQ…"
+}
+ + + + +
rootLoginIdstringCore takes a rootLoginId. A username is also accepted and resolved against localUsers first, so callers need not hash it.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"rootLoginId":"FS8xJ2kQ…"}' \
+  'http://localhost/forget-account'
+
+
+

Response 204

+

No body.

+
Errors

404USER_NOT_FOUND 400BAD_REQUEST

+
+ +
+
+

Check whether a username is free.

+
username-availablesrc/cli/engine/routes/context.ts
+
+

corecontext.usernameAvailable

+ +
+

Command line

+
username-available --username=<username> [--challenge-id=<challengeId>]
+ + + +
+

REST

+

GET/username-available

+ +
+
Query
+
{
+  username: string
+  challengeId?: string
+}
+
Example
{
+  "username": "string",
+  "challengeId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
usernamestringThe name to check.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same check.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/username-available?username=…'
+
+
+

Response 200

+
+
Response body
+
{
+  username: string
+  available: boolean
+}
+
Example
{
+  "username": "string",
+  "available": true
+}
+ + + + + + + + + +
usernamestringThe name that was checked, echoed back.
availablebooleanTrue when nobody holds this name. It is not reserved by asking.
+
+
Errors

400USERNAME_ERROR 403CHALLENGE_REQUIRED 503NETWORK_ERROR

+
+ +
+
+

Normalize a username.

+
fix-usernamesrc/cli/engine/routes/context.ts
+
+

corecontext.fixUsername

+

Applies the same rules the login server does, so a caller can show the user what their name will actually be before creating an account.

+
+
+

Command line

+
fix-username --username=<username>
+ + + +
+

REST

+

GET/fix-username

+ +
+
Query
+
{
+  username: string
+}
+
Example
{
+  "username": "string"
+}
+ + + + +
usernamestringThe name to normalize.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/fix-username?username=…'
+
+
+

Response 200

+
+
Response body
+
{
+  username: string
+}
+
Example
{
+  "username": "string"
+}
+ + + + +
usernamestringThe normalized value. The input is not echoed.
+
+ +
+ +
+
+

Score a candidate password.

+
check-password-rulessrc/cli/engine/routes/context.ts
+
+

corecontext.checkPasswordRules

+ +
+

Command line

+
check-password-rules --password=<password>
+ + + +
+

REST

+

GET/check-password-rules

+ +
+
Query
+
{
+  password: string
+}
+
Example
{
+  "password": "string"
+}
+ + + + +
passwordstringThe candidate password to score.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/check-password-rules?password=…'
+
+
+

Response 200

+

EdgePasswordRules from core: passed, tooShort, noNumber, noLowerCase, noUpperCase, secondsToCrack.

+
unknown
+ +
+

Notes

  • Send it with curl --get --data-urlencode rather than putting it in a shell-visible URL.
+
+
+

Fetch login-server messages for every local user.

+
fetch-login-messagessrc/cli/engine/routes/context.ts
+
+

corecontext.fetchLoginMessages

+ +
+

Command line

+
fetch-login-messages
+ + + +
+

REST

+

GET/fetch-login-messages

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/fetch-login-messages'
+
+
+

Response 200

+

EdgeLoginMessages from core, keyed by loginId; each value carries otpResetPending and pendingVouchers.

+
unknown
+
Errors

503NETWORK_ERROR

+
+ +
+
+

Request a 2FA reset.

+
request-otp-resetsrc/cli/engine/routes/context.ts
+
+

corecontext.requestOtpReset

+

Starts the timed reset a user falls back on after losing their authenticator.

+
+
+

Command line

+
request-otp-reset --username=<username> --otp-reset-token=<otpResetToken>
+ + + +
+

REST

+

POST/request-otp-reset

+ + + +
+
Request body
+
{
+  username: string
+  otpResetToken: string
+}
+
Example
{
+  "username": "string",
+  "otpResetToken": "string"
+}
+ + + + + + + + + +
usernamestringWhose 2FA to reset.
otpResetTokenstringFrom details.resetToken on an OTP_REQUIRED error.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"username":"string","otpResetToken":"string"}' \
+  'http://localhost/request-otp-reset'
+
+
+

Response 200

+

When the reset completes if nobody cancels it.

+
+
Response body
+
{
+  resetDate: string
+}
+
Example
{
+  "resetDate": "2026-09-02T16:35:00.000Z"
+}
+ + + + +
resetDatestringWhen 2FA will actually come off. The login server enforces a waiting period so the real owner has time to cancel.
+
+
Errors

400USERNAME_ERROR 400BAD_REQUEST 503NETWORK_ERROR

+
+ +
+
+

Fetch a user’s recovery questions.

+
fetch-recovery-questionssrc/cli/engine/routes/context.ts
+
+

corecontext.fetchRecovery2Questions Our surface drops the 2 from the path, command and recoveryKey parameter; a future Recovery1 would be suffixed V1.

Differs from core:

  • recoveryKey — Core calls it recovery2Key. The 2 is dropped throughout.
+ +
+

Command line

+
fetch-recovery-questions --recovery-key=<recoveryKey> --username=<username>
+ + + +
+

REST

+

GET/fetch-recovery-questions

+ +
+
Query
+
{
+  recoveryKey: string
+  username: string
+}
+
Example
{
+  "recoveryKey": "string",
+  "username": "string"
+}
+ + + + + + + + + +
recoveryKeystringFrom change-recovery, stored by the user out of band.
usernamestringWhose questions to fetch.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/fetch-recovery-questions?recoveryKey=…&username=…'
+
+
+

Response 200

+
+
Response body
+
{
+  questions: string[]
+}
+
Example
{
+  "questions": [
+    "string"
+  ]
+}
+ + + + +
questionsstring[]The questions in the order login-with-recovery expects the answers.
+
+
Errors

400USERNAME_ERROR 503NETWORK_ERROR

+
+ +
+
+

Pre-fetch a CAPTCHA challenge.

+
fetch-challengesrc/cli/engine/routes/context.ts
+
+

corecontext.fetchChallenge

+

Lets a client solve a challenge before it hits 403 CHALLENGE_REQUIRED mid-flow.

+
+
+

Command line

+
fetch-challenge
+ + + +
+

REST

+

POST/fetch-challenge

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/fetch-challenge'
+
+
+

Response 200

+

challengeUri is absent when the server considers the challenge already satisfied.

+
+
Response body
+
{
+  challengeId: string
+  challengeUri?: string
+}
+
Example
{
+  "challengeId": "FS8xJ2kQ…",
+  "challengeUri": "string"
+}
+ + + + + + + + + +
challengeIdstringPass to the call that demanded a challenge once the user has solved it.
challengeUristring optionalWhere to send the user to solve the CAPTCHA. Absent when the server issued a challenge that needs no interaction.
+
+
Errors

503NETWORK_ERROR

+
+ +
+
+

List plugin ids usable for wallet creation.

+
currency-configssrc/cli/engine/routes/context.ts
+
+

coreEngine view of the enabled plugin set; core exposes account.currencyConfig per plugin instead.

+

Currency and accountbased plugins only — swap plugins are excluded.

+
+
+

Command line

+
currency-configs
+ + + +
+

REST

+

GET/currency-configs

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/currency-configs'
+
+
+

Response 200

+
+
Response body
+
{
+  pluginIds: string[]
+}
+
Example
{
+  "pluginIds": [
+    "string"
+  ]
+}
+ + + + +
pluginIdsstring[]Currency plugins this engine loaded.
+
+ +
+ +

Login methods

+

Every successful login returns a Session and registers it in the engine, so later calls need only the sessionId. The CLI writes that id to session.json automatically.

+
+
+
+

Log in with a password.

+
login-with-passwordsrc/cli/engine/routes/login.ts
+
+

corecontext.loginWithPassword

+ +
+

Command line

+
login-with-password [--otp=<otp>] [--otp-key=<otpKey>] [--challenge-id=<challengeId>] --username=<username> --password=<password>
+ + + +
+

REST

+

POST/login-with-password

+ + + +
+
Request body
+
{
+  otp?: string
+  otpKey?: string
+  challengeId?: string
+  username: string
+  password: string
+}
+
Example
{
+  "otp": "string",
+  "otpKey": "string",
+  "challengeId": "FS8xJ2kQ…",
+  "username": "string",
+  "password": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + +
otpstring optionalA current 2FA code.
otpKeystring optionalThe 2FA secret itself, instead of a code.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same request.
usernamestringThe account name.
passwordstringThe account password.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otp":"string","otpKey":"string","challengeId":"FS8xJ2kQ…","username":"string","password":"string"}' \
+  'http://localhost/login-with-password'
+
+
+

Response 200

+

A session with loginMethod: "password".

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

401PASSWORD_ERROR 400USERNAME_ERROR 401OTP_REQUIRED 403CHALLENGE_REQUIRED 503NETWORK_ERROR

+
+

Notes

  • With --solve-captcha the client solves a CHALLENGE_REQUIRED response headlessly (ALTCHA proof-of-work) and retries once.
+
+
+

Log in with a device PIN.

+
login-with-pinsrc/cli/engine/routes/login.ts
+
+

corecontext.loginWithPIN

+

Only works on a device that has already saved a PIN for the account.

+
+
+

Command line

+
login-with-pin [--otp=<otp>] [--otp-key=<otpKey>] [--challenge-id=<challengeId>] --username-or-login-id=<usernameOrLoginId> --pin=<pin> [--use-login-id[=false]]
+ + + +
+

REST

+

POST/login-with-pin

+ + + +
+
Request body
+
{
+  otp?: string
+  otpKey?: string
+  challengeId?: string
+  usernameOrLoginId: string
+  pin: string
+  useLoginId?: boolean
+}
+
Example
{
+  "otp": "string",
+  "otpKey": "string",
+  "challengeId": "FS8xJ2kQ…",
+  "usernameOrLoginId": "FS8xJ2kQ…",
+  "pin": "string",
+  "useLoginId": true
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
otpstring optionalA current 2FA code.
otpKeystring optionalThe 2FA secret itself, instead of a code.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same request.
usernameOrLoginIdstringA username, or a login id.
pinstringThe device PIN.
useLoginIdboolean optionalTreat the value as a login id.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otp":"string","otpKey":"string","challengeId":"FS8xJ2kQ…","usernameOrLoginId":"FS8xJ2kQ…","pin":"string","useLoginId":true}' \
+  'http://localhost/login-with-pin'
+
+
+

Response 200

+

A session with loginMethod: "pin".

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

401PASSWORD_ERROR 403PIN_DISABLED 400USERNAME_ERROR 400BAD_REQUEST 503NETWORK_ERROR

+
+ +
+
+

Log in with an account login key.

+
login-with-keysrc/cli/engine/routes/login.ts
+
+

corecontext.loginWithKey

+

The key comes from get-login-key on an already-authenticated session.

+
+
+

Command line

+
login-with-key [--otp=<otp>] [--otp-key=<otpKey>] [--challenge-id=<challengeId>] --username-or-login-id=<usernameOrLoginId> --login-key=<loginKey> [--use-login-id[=false]]
+ + + +
+

REST

+

POST/login-with-key

+ + + +
+
Request body
+
{
+  otp?: string
+  otpKey?: string
+  challengeId?: string
+  usernameOrLoginId: string
+  loginKey: string
+  useLoginId?: boolean
+}
+
Example
{
+  "otp": "string",
+  "otpKey": "string",
+  "challengeId": "FS8xJ2kQ…",
+  "usernameOrLoginId": "FS8xJ2kQ…",
+  "loginKey": "string",
+  "useLoginId": true
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
otpstring optionalA current 2FA code.
otpKeystring optionalThe 2FA secret itself, instead of a code.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same request.
usernameOrLoginIdstringA username, or a login id.
loginKeystringFrom get-login-key.
useLoginIdboolean optionalTreat the value as a login id.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otp":"string","otpKey":"string","challengeId":"FS8xJ2kQ…","usernameOrLoginId":"FS8xJ2kQ…","loginKey":"string","useLoginId":true}' \
+  'http://localhost/login-with-key'
+
+
+

Response 200

+

A session with loginMethod: "key".

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

401PASSWORD_ERROR 400USERNAME_ERROR 503NETWORK_ERROR

+
+ +
+
+

Log in with recovery answers.

+
login-with-recoverysrc/cli/engine/routes/login.ts
+
+

corecontext.loginWithRecovery2 Our surface drops the 2 from core's recovery2 naming, and calls the key recoveryKey to match what change-recovery returns.

Differs from core:

  • recoveryKey — Core calls it recovery2Key. The 2 is dropped throughout.
+

Needs both the recovery key and the answers; neither works alone.

+
+
+

Command line

+
login-with-recovery [--otp=<otp>] [--otp-key=<otpKey>] [--challenge-id=<challengeId>] --recovery-key=<recoveryKey> --username=<username> --answer=<answers> …
+ + + +
+

REST

+

POST/login-with-recovery

+ + + +
+
Request body
+
{
+  otp?: string
+  otpKey?: string
+  challengeId?: string
+  recoveryKey: string
+  username: string
+  answers: string[]
+}
+
Example
{
+  "otp": "string",
+  "otpKey": "string",
+  "challengeId": "FS8xJ2kQ…",
+  "recoveryKey": "string",
+  "username": "string",
+  "answers": [
+    "string"
+  ]
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
otpstring optionalA current 2FA code.
otpKeystring optionalThe 2FA secret itself, instead of a code.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same request.
recoveryKeystringFrom change-recovery.
usernamestringThe account name.
answersstring[]In the same order as the questions.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otp":"string","otpKey":"string","challengeId":"FS8xJ2kQ…","recoveryKey":"string","username":"string","answers":["string"]}' \
+  'http://localhost/login-with-recovery'
+
+
+

Response 200

+

A session with loginMethod: "recovery".

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

401PASSWORD_ERROR 400USERNAME_ERROR 503NETWORK_ERROR

+
+ +
+
+

Create an account.

+
create-accountsrc/cli/engine/routes/login.ts
+
+

corecontext.createAccount

+

Every credential is optional over REST: omitting all three creates a light account with no username.

+
+
+

Command line

+
create-account [--otp=<otp>] [--otp-key=<otpKey>] [--challenge-id=<challengeId>] [--username=<username>] [--password=<password>] [--pin=<pin>]
+ + + +
+

REST

+

POST/create-account

+ + + +
+
Request body
+
{
+  otp?: string
+  otpKey?: string
+  challengeId?: string
+  username?: string
+  password?: string
+  pin?: string
+}
+
Example
{
+  "otp": "string",
+  "otpKey": "string",
+  "challengeId": "FS8xJ2kQ…",
+  "username": "string",
+  "password": "string",
+  "pin": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
otpstring optionalA current 2FA code.
otpKeystring optionalThe 2FA secret itself, instead of a code.
challengeIdstring optionalSupply after solving a CAPTCHA to retry the same request.
usernamestring optionalThe name to claim.
passwordstring optionalThe account password.
pinstring optionalA device PIN to save.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otp":"string","otpKey":"string","challengeId":"FS8xJ2kQ…","username":"string","password":"string","pin":"string"}' \
+  'http://localhost/create-account'
+
+
+

Response 200

+

A session with loginMethod: "create".

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

400USERNAME_ERROR 403CHALLENGE_REQUIRED 400BAD_REQUEST 503NETWORK_ERROR

+
+

Notes

  • The command requires a username, password and PIN. Creating a light account is REST-only.
+
+
+

Start a QR login.

+
request-edge-loginsrc/cli/engine/routes/login.ts
+
+

corecontext.requestEdgeLogin

+

Asks the login server for a lobby another logged-in Edge device can approve. The returned lobbyId is what goes in the QR code.

+
+
+

Command line

+
request-edge-login [--no-wait]
+ +
Client-only flags
--no-waitoptionalPrint the lobby and exit instead of polling, so the QR can be displayed while poll-edge-login watches the same handle from another process.
+

Prints the pending login, then polls every 2s for up to 5 minutes. On done it stores the session. With --no-wait it returns immediately and poll-edge-login takes over.

+
+
+

REST

+

POST/request-edge-login

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/request-edge-login'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  pendingId: string
+  kind: string
+  expiresAt: string | null
+  lobbyId: string
+  uri: string
+  state: string
+  username: string | null
+  session: {
+    sessionId: string;
+    username: string | undefined;
+    rootLoginId: string;
+    loginMethod: "password" | "pin" | "edge" | "key" | "create" | "recovery";
+    autoLogoutSeconds: number;
+    expiresAt: string | null;
+    lastActivityAt: string;
+    createdAt: string
+  } | null
+  error: string | null
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "pendingId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lobbyId": "FS8xJ2kQ…",
+  "uri": "string",
+  "state": "string",
+  "username": "string",
+  "session": {},
+  "error": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
pendingIdstringSame value as objectId, under the name the poll command takes.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstring | nullWhen the lobby closes and the QR code stops working.
lobbyIdstringLobby the phone connects to.
uristringThe edge:// URI to render as a QR code for the phone to scan.
statestringHow far the login has got: pending before the phone scans, started once it has, and done when session is filled in.
usernamestring | nullAccount that approved the login, known once the phone has scanned.
session{ sessionId: string; username: string | undefined; rootLoginId: string; loginMethod: "password" | "pin" | "edge" | "key" | "create" | "recovery"; autoLogoutSeconds: number; expiresAt: string | null; lastActivityAt: string; createdAt: string; } | nullThe session, null until state is done.
errorstring | nullWhy the login failed, set only when state is error.
+
+
Errors

503NETWORK_ERROR

+
+

Notes

  • The pending login is an object handle with a 5 minute TTL. On expiry the engine cancels the request on the login server for you.
+
+
+

Poll a pending QR login.

+
poll-edge-loginsrc/cli/engine/routes/login.ts
+
+

coreEngine state for an in-flight requestEdgeLogin; core exposes it as EdgePendingEdgeLogin properties.

+

Once state reaches done the engine has already created the session, so the response carries one ready to use.

+
+
+

Command line

+
poll-edge-login <pendingId>
+ + + +
+

REST

+

GET/pending-edge-login/{pendingId}

+
Path
pendingIdstringThe pendingId returned when the QR login was requested.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/pending-edge-login/$PENDINGID'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  pendingId: string
+  kind: string
+  expiresAt: string | null
+  lobbyId: string
+  uri: string
+  state: string
+  username: string | null
+  session: {
+    sessionId: string;
+    username: string | undefined;
+    rootLoginId: string;
+    loginMethod: "password" | "pin" | "edge" | "key" | "create" | "recovery";
+    autoLogoutSeconds: number;
+    expiresAt: string | null;
+    lastActivityAt: string;
+    createdAt: string
+  } | null
+  error: string | null
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "pendingId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lobbyId": "FS8xJ2kQ…",
+  "uri": "string",
+  "state": "string",
+  "username": "string",
+  "session": {},
+  "error": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
pendingIdstringSame value as objectId, under the name the poll command takes.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstring | nullWhen the lobby closes and the QR code stops working.
lobbyIdstringLobby the phone connects to.
uristringThe edge:// URI to render as a QR code for the phone to scan.
statestringHow far the login has got: pending before the phone scans, started once it has, and done when session is filled in.
usernamestring | nullAccount that approved the login, known once the phone has scanned.
session{ sessionId: string; username: string | undefined; rootLoginId: string; loginMethod: "password" | "pin" | "edge" | "key" | "create" | "recovery"; autoLogoutSeconds: number; expiresAt: string | null; lastActivityAt: string; createdAt: string; } | nullThe session, null until state is done.
errorstring | nullWhy the login failed, set only when state is error.
+
+
Errors

404PENDING_LOGIN_NOT_FOUND 410OBJECT_EXPIRED

+
+

Notes

  • Session creation is attempted once. A failure is sticky, so later polls report the same error rather than retrying.
  • Polling does not extend the handle TTL; only the original 5 minute window applies.
+
+
+

Cancel a pending QR login.

+
cancel-requestsrc/cli/engine/routes/login.ts
+
+

coreEdgePendingEdgeLogin.cancelRequest

+ +
+

Command line

+
cancel-request <pendingId>
+ + + +
+

REST

+

POST/pending-edge-login/cancel-request/{pendingId}

+
Path
pendingIdstringThe pendingId returned when the QR login was requested.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/pending-edge-login/cancel-request/$PENDINGID'
+
+
+

Response 204

+

No body.

+
Errors

404PENDING_LOGIN_NOT_FOUND

+
+

Notes

  • If the login already completed and a session exists, that session is force-logged-out too, so cancelling cannot leave an orphan visible in engine-sessions.
+
+
+

List active sessions.

+
engine-sessionssrc/cli/engine/routes/login.ts
+
+

coreThe session registry is an engine construct; core has no multi-account session concept.

+ +
+

Command line

+
engine-sessions
+ + + +
+

REST

+

GET/engine/sessions

+ + + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/engine/sessions'
+
+
+

Response 200

+

A bare array, not wrapped in a key. Each sessionId is truncated: this route needs no session, so a usable id here would be a credential anyone who can reach the engine could collect.

+
{
+  sessionId: string;
+  username: string | undefined;
+  rootLoginId: string;
+  loginMethod: "password" | "pin" | "edge" | "key" | "create" | "recovery";
+  autoLogoutSeconds: number;
+  expiresAt: string | null;
+  lastActivityAt: string;
+  createdAt: string
+}[]
+ +
+ +

Account

+

Calls on a logged-in EdgeAccount, addressed by sessionId. All of these can also return 401 INVALID_SESSION or 401 SESSION_EXPIRED.

+
+

Session

+

Calls on a logged-in EdgeAccount, addressed by sessionId. All of these can also return 401 INVALID_SESSION or 401 SESSION_EXPIRED.

+
+
+
+

Account and session summary.

+
account-infosrc/cli/engine/routes/account.ts
+
+

coreEngine composite of the session record plus EdgeAccount properties.

+

Session fields are spread at the top level alongside the account's own properties — there is no nested session object.

+
+
+

Command line

+
account-info
+ + + +
+

REST

+

GET/account/{sessionId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS'
+
+
+

Response 200

+
+
Response body
+
{
+  appId: string
+  created: string | null
+  lastLogin: string
+  loggedIn: boolean
+  recoveryKey: string | null
+  otpEnabled: boolean
+  otpResetPending: boolean
+  canDuressLogin: boolean
+  isDuressAccount: boolean
+  edgeLogin: boolean
+  keyLogin: boolean
+  newAccount: boolean
+  passwordLogin: boolean
+  pinLogin: boolean
+  recoveryLogin: boolean
+}
+
Example
{
+  "appId": "FS8xJ2kQ…",
+  "created": "string",
+  "lastLogin": "string",
+  "loggedIn": true,
+  "recoveryKey": "string",
+  "otpEnabled": true,
+  "otpResetPending": true,
+  "canDuressLogin": true,
+  "isDuressAccount": true,
+  "edgeLogin": true,
+  "keyLogin": true,
+  "newAccount": true,
+  "passwordLogin": true,
+  "pinLogin": true,
+  "recoveryLogin": true
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
appIdstringApplication this session logged into.
createdstring | nullWhen the account was created, null for accounts predating the field.
lastLoginstringThe previous login, not this one.
loggedInbooleanFalse once the account has been logged out; the session object outlives it briefly.
recoveryKeystring | nullPresent only while recovery is configured.
otpEnabledboolean2FA is on for this account.
otpResetPendingbooleanTrue while somebody has a reset pending against this account.
canDuressLoginbooleanA duress PIN is configured, so this account can be opened in duress mode.
isDuressAccountbooleanTrue when this very session is the duress account rather than the real one.
edgeLoginbooleanThis account was reached by QR login.
keyLoginbooleanThis session was reached with a login key.
newAccountbooleanThis session created the account rather than logging into an existing one.
passwordLoginbooleanThis session was reached with a password.
pinLoginbooleanThis session was reached with a PIN.
recoveryLoginbooleanThis session was reached by answering recovery questions.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The otpEnabled and otpResetPending flags here are derived. For the secret itself use otp-key.
+
+
+

Log out.

+
logoutsrc/cli/engine/routes/account.ts
+
+

coreaccount.logout

+

Ends the session and drops it from the engine. Any subscription scoped to this account or its wallets is closed with it.

+
+
+

Command line

+
logout
+ + +

Also clears the stored id from session.json.

+
+
+

REST

+

POST/account/{sessionId}/logout

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/logout'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Keepalive.

+
touchsrc/cli/engine/routes/account.ts
+
+

coreEngine auto-logout timer; core has no idle concept.

+

Resets the idle auto-logout timer without doing any other work.

+
+
+

Command line

+
touch
+ + + +
+

REST

+

POST/account/{sessionId}/touch

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/touch'
+
+
+

Response 200

+

The session, with a refreshed expiresAt.

+
+
Response body
+
{
+  sessionId: string
+  username?: string
+  rootLoginId: string
+  loginMethod: "create" | "edge" | "key" | "password" | "pin" | "recovery"
+  autoLogoutSeconds: number
+  expiresAt: string | null
+  lastActivityAt: string
+  createdAt: string
+}
+
Example
{
+  "sessionId": "FS8xJ2kQ…",
+  "username": "string",
+  "rootLoginId": "FS8xJ2kQ…",
+  "loginMethod": "<\"create\">",
+  "autoLogoutSeconds": 1,
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lastActivityAt": "2026-09-02T16:35:00.000Z",
+  "createdAt": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sessionIdstringIdentifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it.
usernamestring optionalAbsent for a light account, which has no username.
rootLoginIdstringThe account root, stable across appIds. Two sessions sharing it are the same account.
loginMethod"create" | "edge" | "key" | "password" | "pin" | "recovery"How this session was established.
autoLogoutSecondsnumberIdle time before the engine logs the account out. 0 disables it.
expiresAtstring | nullWhen auto-logout will fire, or null when it is disabled.
lastActivityAtstringLast call on this session, which is what auto-logout measures from.
createdAtstringWhen the login completed.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Read the account login key.

+
get-login-keysrc/cli/engine/routes/account.ts
+
+

coreaccount.getLoginKey

+

The key login-with-key takes. It grants full account access, so treat the output as secret.

+
+
+

Command line

+
get-login-key
+ + + +
+

REST

+

GET/account/{sessionId}/get-login-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-login-key'
+
+
+

Response 200

+
+
Response body
+
{
+  loginKey: string
+}
+
Example
{
+  "loginKey": "string"
+}
+ + + + +
loginKeystringbase58. Full account access — keep it safe.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Force an account data sync.

+
syncsrc/cli/engine/routes/account.ts
+
+

coreaccount.sync

+

Pushes and pulls the account repos immediately rather than waiting for the next scheduled sync.

+
+
+

Command line

+
sync
+ + +

Named sync for the account; the wallet one is wallet-sync.

+
+
+

REST

+

POST/account/{sessionId}/sync

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/sync'
+
+
+

Response 204

+

No body.

+
Errors

503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Permanently delete the remote account.

+
delete-remote-accountsrc/cli/engine/routes/account.ts
+
+

coreaccount.deleteRemoteAccount

+

Irreversible. The account is removed from the login server, and funds in its wallets are unrecoverable without the keys. The session is logged out afterwards.

+
+
+

Command line

+
delete-remote-account --yes
+ +
Client-only flags
--yesrequiredConfirms intent. Without it the command refuses to run.
+ +
+

REST

+

POST/account/{sessionId}/delete-remote-account

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/delete-remote-account'
+
+
+

Response 204

+

No body.

+
Errors

503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The engine performs no confirmation check — the call runs as soon as it arrives, so any guard has to live in the caller. The command requires --yes for exactly this reason.
+
+
+

Wait for every wallet to finish loading.

+
wait-for-all-walletssrc/cli/engine/routes/account.ts
+
+

coreaccount.waitForAllWallets

+

Wallets load in the background after login, so a list taken straight afterwards can be short. This resolves once each active wallet has either loaded or failed — balances may still be syncing afterwards.

+
+
+

Command line

+
wait-for-all-wallets
+ + + +
+

REST

+

POST/account/{sessionId}/wait-for-all-wallets

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/wait-for-all-wallets'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • There is no timeout: a wallet that never resolves holds this open. The engine's own idle shutdown does not fire while a request is in flight, so give the client one.
  • Nothing is returned. Call currency-wallets afterwards to see the result, including any wallet that failed to load.
+
+
+

List the account's wallets.

+
currency-walletssrc/cli/engine/routes/account.ts
+
+

coreaccount.currencyWallets Filtered by account.activeWalletIds / archivedWalletIds / hiddenWalletIds.

Differs from core:

  • filter — Core has no filter: it exposes activeWalletIds, archivedWalletIds and hiddenWalletIds as separate lists. This picks between them.
+ +
+

Command line

+
currency-wallets [--filter=active|all|archived|hidden]
+ + + +
+

REST

+

GET/account/{sessionId}/currency-wallets

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  filter?: "active" | "all" | "archived" | "hidden"
+}
+
Example
{
+  "filter": "<\"active\">"
+}
+ + + + +
filter"active" | "all" | "archived" | "hidden" optionalWhich of the account’s wallet lists to read. Defaults to active.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/currency-wallets'
+
+
+

Response 200

+
+
Response body
+
{
+  currencyWallets: {
+    walletId: string;
+    id: string;
+    type: string;
+    name: string | null;
+    pluginId: string;
+    currencyCode: string;
+    fiatCurrencyCode: string;
+    blockHeight: number;
+    syncStatus: unknown;
+    syncRatio: string | undefined;
+    paused: boolean;
+    imported: boolean | undefined;
+    created: string | null;
+    enabledTokenIds: string[];
+    detectedTokenIds: string[];
+    unactivatedTokenIds: string[]
+  }[]
+}
+
Example
{
+  "currencyWallets": [
+    {}
+  ]
+}
+ + + + +
currencyWallets{ walletId: string; id: string; type: string; name: string | null; pluginId: string; currencyCode: string; fiatCurrencyCode: string; blockHeight: number; syncStatus: unknown; syncRatio: string | undefined; paused: boolean; imported: boolean | undefined; created: string | null; enabledTokenIds: string[]; detectedTokenIds: string[]; unactivatedTokenIds: string[]; }[]Every wallet in the account, including paused ones.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Wallets load in the background after login, so a list taken straight afterwards can be short. Call wait-for-all-wallets first to be sure the account has finished loading.
+
+
+

Create a currency wallet.

+
create-currency-walletsrc/cli/engine/routes/account.ts
+
+

coreaccount.createCurrencyWallet

+ +
+

Command line

+
create-currency-wallet --wallet-type=<walletType> [--name=<name>] [--import-text=<importText>]
+ + + +
+

REST

+

POST/account/{sessionId}/create-currency-wallet

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletType: string
+  name?: string
+  importText?: string
+}
+
Example
{
+  "walletType": "string",
+  "name": "string",
+  "importText": "string"
+}
+ + + + + + + + + + + + + + +
walletTypestringFrom currency-configs, e.g. wallet:bitcoin.
namestring optionalDisplay name.
importTextstring optionalSeed or key text to import instead of generating.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletType":"string","name":"string","importText":"string"}' \
+  'http://localhost/account/$SESS/create-currency-wallet'
+
+
+

Response 200

+
+
Response body
+
{
+  walletId: string
+  id: string
+  type: string
+  name: string | null
+  pluginId: string
+  currencyCode: string
+  fiatCurrencyCode: string
+  blockHeight: number
+  syncStatus: unknown
+  syncRatio?: string
+  paused: boolean
+  imported?: boolean
+  created: string | null
+  enabledTokenIds: string[]
+  detectedTokenIds: string[]
+  unactivatedTokenIds: string[]
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "id": "FS8xJ2kQ…",
+  "type": "string",
+  "name": "string",
+  "pluginId": "FS8xJ2kQ…",
+  "currencyCode": "string",
+  "fiatCurrencyCode": "string",
+  "blockHeight": 1,
+  "syncStatus": {},
+  "syncRatio": "string",
+  "paused": true,
+  "imported": true,
+  "created": "string",
+  "enabledTokenIds": [
+    "string"
+  ],
+  "detectedTokenIds": [
+    "string"
+  ],
+  "unactivatedTokenIds": [
+    "string"
+  ]
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe full wallet id. Commands taking a wallet accept any unique prefix.
idstringSame value as walletId; core exposes both names.
typestringKey type, such as wallet:bitcoin.
namestring | nullUser-assigned name, null until one is set.
pluginIdstringCurrency plugin backing this wallet.
currencyCodestringTicker for the native asset.
fiatCurrencyCodestringFiat the wallet reports value in, as iso:USD.
blockHeightnumberChain height this wallet has seen.
syncStatusunknownEdgeWalletSyncStatus from core.
syncRatiostring optionalSync progress as a percentage, for display.
pausedbooleanTrue while the engine is not syncing this wallet.
importedboolean optionalTrue when the keys came from an import rather than being generated here.
createdstring | nullWhen the wallet was created, null for wallets predating the field.
enabledTokenIdsstring[]Tokens the user turned on.
detectedTokenIdsstring[]Tokens found on-chain that are not enabled yet.
unactivatedTokenIdsstring[]Enabled tokens still awaiting on-chain activation.
+
+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The fiat currency is not set here. Core still accepts it on create, but that path is deprecated — use set-fiat-currency-code afterwards, so there is one way to do it.
+
+
+

Create several wallets at once.

+
create-currency-walletssrc/cli/engine/routes/account.ts
+
+

coreaccount.createCurrencyWallets

+

Partial success is normal: each entry reports its own outcome, and one failure does not roll back the others.

+
+
+

Command line

+
create-currency-wallets --create-wallets='<json>'
+ + + +
+

REST

+

POST/account/{sessionId}/create-currency-wallets

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  createWallets: {
+    walletType: string;
+    name: string | undefined;
+    fiatCurrencyCode: string | undefined
+  }[]
+}
+
Example
{
+  "createWallets": [
+    {}
+  ]
+}
+ + + + +
createWallets{ walletType: string; name: string | undefined; fiatCurrencyCode: string | undefined; }[]EdgeCreateCurrencyWallet[]: walletType, plus optional name and fiatCurrencyCode.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"createWallets":[{}]}' \
+  'http://localhost/account/$SESS/create-currency-wallets'
+
+
+

Response 200

+
+
Response body
+
{
+  results: unknown[]
+}
+
Example
{
+  "results": [
+    {}
+  ]
+}
+ + + + +
resultsunknown[]Mirrors core’s EdgeResult[]: { ok, wallet } or { ok: false, error }.
+
+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Credentials

+

Password, PIN, username and recovery changes on a logged-in account.

+
+
+
+

Set or change the password.

+
change-passwordsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.changePassword

+

The login server enforces its own rules; check-password-rules scores a candidate first.

+
+
+

Command line

+
change-password --password=<password>
+ + + +
+

REST

+

POST/account/{sessionId}/change-password

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  password: string
+}
+
Example
{
+  "password": "string"
+}
+ + + + +
passwordstringThe new password.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"password":"string"}' \
+  'http://localhost/account/$SESS/change-password'
+
+ + +
+
+

Remove password login.

+
delete-passwordsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.deletePassword

+

The account keeps its other login methods; only the password stops working.

+
+
+

Command line

+
delete-password
+ + + +
+

REST

+

POST/account/{sessionId}/delete-password

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/delete-password'
+
+ + +
+
+

Verify a password.

+
check-passwordsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.checkPassword

+

Checks without changing anything, which is how a caller gates a destructive action behind a re-entry prompt.

+
+
+

Command line

+
check-password --password=<password>
+ + + +
+

REST

+

POST/account/{sessionId}/check-password

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  password: string
+}
+
Example
{
+  "password": "string"
+}
+ + + + +
passwordstringThe account password.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"password":"string"}' \
+  'http://localhost/account/$SESS/check-password'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+}
+
Example
{
+  "ok": true
+}
+ + + + +
okbooleanFalse for a wrong password — not an error response.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Read the account PIN.

+
get-pinsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.getPin

+

Returns the PIN itself, not a status flag, so treat the output as secret.

+
+
+

Command line

+
get-pin
+ + + +
+

REST

+

GET/account/{sessionId}/get-pin

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-pin'
+
+
+

Response 200

+
+
Response body
+
{
+  pin: string | null
+}
+
Example
{
+  "pin": "string"
+}
+ + + + +
pinstring | nullNull when no PIN is set.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Set or change the PIN.

+
change-pinsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.changePin

+ +
+

Command line

+
change-pin --pin=<pin> [--enable-login[=false]] [--for-duress-account[=false]]
+ + + +
+

REST

+

POST/account/{sessionId}/change-pin

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  pin: string
+  enableLogin?: boolean
+  forDuressAccount?: boolean
+}
+
Example
{
+  "pin": "string",
+  "enableLogin": true,
+  "forDuressAccount": true
+}
+ + + + + + + + + + + + + + +
pinstringThe new PIN.
enableLoginboolean optionalAllow logging in with this PIN on this device.
forDuressAccountboolean optionalAct on the duress account rather than the real one.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"pin":"string","enableLogin":true,"forDuressAccount":true}' \
+  'http://localhost/account/$SESS/change-pin'
+
+
+

Response 200

+
+
Response body
+
{
+  pin2Key: string
+}
+
Example
{
+  "pin2Key": "string"
+}
+ + + + +
pin2KeystringThe new PIN login key core returns.
+
+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Remove the PIN.

+
delete-pinsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.deletePin

+

PIN login stops working on this device; other methods are untouched.

+
+
+

Command line

+
delete-pin
+ + + +
+

REST

+

POST/account/{sessionId}/delete-pin

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/delete-pin'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Verify a PIN.

+
check-pinsrc/cli/engine/routes/credentials.ts
+
+

coreaccount.checkPin

+ +
+

Command line

+
check-pin --pin=<pin> [--for-duress-account[=false]]
+ + + +
+

REST

+

POST/account/{sessionId}/check-pin

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  pin: string
+  forDuressAccount?: boolean
+}
+
Example
{
+  "pin": "string",
+  "forDuressAccount": true
+}
+ + + + + + + + + +
pinstringThe device PIN, usually four digits.
forDuressAccountboolean optionalAct on the duress account rather than the real one.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"pin":"string","forDuressAccount":true}' \
+  'http://localhost/account/$SESS/check-pin'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+}
+
Example
{
+  "ok": true
+}
+ + + + +
okbooleanFalse for a wrong PIN — not an error response.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Change the username.

+
change-usernamesrc/cli/engine/routes/credentials.ts
+
+

coreaccount.changeUsername

+

The old name is released, so it becomes available to anyone else.

+
+
+

Command line

+
change-username --username=<username> [--password=<password>]
+ + + +
+

REST

+

POST/account/{sessionId}/change-username

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  username: string
+  password?: string
+}
+
Example
{
+  "username": "string",
+  "password": "string"
+}
+ + + + + + + + + +
usernamestringThe new username.
passwordstring optionalRequired by core when the account has a password.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"username":"string","password":"string"}' \
+  'http://localhost/account/$SESS/change-username'
+
+ + +
+
+

Set recovery questions and answers.

+
change-recoverysrc/cli/engine/routes/credentials.ts
+
+

coreaccount.changeRecovery Our surface drops the 2 from core's recovery2 naming; a future Recovery1 would be suffixed V1.

+

The returned key is half of the credential: without it the answers alone cannot recover the account, so it has to be stored somewhere else.

+
+
+

Command line

+
change-recovery --question=<questions> … --answer=<answers> …
+ + + +
+

REST

+

POST/account/{sessionId}/change-recovery

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  questions: string[]
+  answers: string[]
+}
+
Example
{
+  "questions": [
+    "string"
+  ],
+  "answers": [
+    "string"
+  ]
+}
+ + + + + + + + + +
questionsstring[]The questions to ask.
answersstring[]Same length and order as questions.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"questions":["string"],"answers":["string"]}' \
+  'http://localhost/account/$SESS/change-recovery'
+
+
+

Response 200

+
+
Response body
+
{
+  recoveryKey: string
+}
+
Example
{
+  "recoveryKey": "string"
+}
+ + + + +
recoveryKeystringStore this out of band. login-with-recovery needs it alongside the answers.
+
+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Disable recovery login.

+
delete-recoverysrc/cli/engine/routes/credentials.ts
+
+

coreaccount.deleteRecovery

+

The existing recovery key stops working.

+
+
+

Command line

+
delete-recovery
+ + + +
+

REST

+

POST/account/{sessionId}/delete-recovery

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/delete-recovery'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Two-factor authentication

+

OTP state and the reset flow a user falls back on after losing their authenticator.

+
+
+
+

Read the 2FA secret and reset state.

+
otp-keysrc/cli/engine/routes/otp.ts
+
+

coreaccount.otpKey Also carries account.otpResetDate.

+ +
+

Command line

+
otp-key
+ + + +
+

REST

+

GET/account/{sessionId}/otp-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/otp-key'
+
+
+

Response 200

+
+
Response body
+
{
+  otpKey: string | null
+  otpResetDate: string | null
+}
+
Example
{
+  "otpKey": "string",
+  "otpResetDate": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + +
otpKeystring | nullNull when 2FA is off. The 2FA secret itself. Secret material — record it safely.
otpResetDatestring | nullSet once somebody has requested a reset; cancel it with cancel-otp-reset.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Enable 2FA.

+
enable-otpsrc/cli/engine/routes/otp.ts
+
+

coreaccount.enableOtp

+

Record the returned key before leaving the terminal: it is the only copy.

+
+
+

Command line

+
enable-otp [--timeout=<timeout>]
+ + + +
+

REST

+

POST/account/{sessionId}/enable-otp

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  timeout?: number
+}
+
Example
{
+  "timeout": 0
+}
+ + + + +
timeoutnumber optionalHow long a reset request must wait before it completes, in seconds. Core supplies the default when omitted.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"timeout":0}' \
+  'http://localhost/account/$SESS/enable-otp'
+
+
+

Response 200

+
+
Response body
+
{
+  otpKey: string | null
+}
+
Example
{
+  "otpKey": "string"
+}
+ + + + +
otpKeystring | nullThe new secret. The 2FA secret itself. Secret material — record it safely.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Disable 2FA.

+
disable-otpsrc/cli/engine/routes/otp.ts
+
+

coreaccount.disableOtp

+

Logins stop requiring a code immediately.

+
+
+

Command line

+
disable-otp
+ + + +
+

REST

+

POST/account/{sessionId}/disable-otp

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/disable-otp'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Cancel a pending 2FA reset.

+
cancel-otp-resetsrc/cli/engine/routes/otp.ts
+
+

coreaccount.cancelOtpReset

+

The defence against somebody else requesting a reset on your account: as long as you cancel before the timer runs out, their reset never lands.

+
+
+

Command line

+
cancel-otp-reset
+ + + +
+

REST

+

POST/account/{sessionId}/cancel-otp-reset

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/cancel-otp-reset'
+
+
+

Response 204

+

No body.

+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Re-point the account at a known 2FA secret.

+
repair-otpsrc/cli/engine/routes/otp.ts
+
+

coreaccount.repairOtp

+

For a device whose stored secret has drifted from the server's.

+
+
+

Command line

+
repair-otp --otp-key=<otpKey>
+ + + +
+

REST

+

POST/account/{sessionId}/repair-otp

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  otpKey: string
+}
+
Example
{
+  "otpKey": "string"
+}
+ + + + +
otpKeystringThe secret the account should use.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"otpKey":"string"}' \
+  'http://localhost/account/$SESS/repair-otp'
+
+ + +

Vouchers

+

When 2FA blocks a login, the login server issues a voucher an already-trusted device can approve or reject.

+
+
+
+

List pending 2FA vouchers.

+
pending-voucherssrc/cli/engine/routes/vouchers.ts
+
+

coreaccount.pendingVouchers

+

When 2FA blocks a login, the login server issues a voucher that an already-trusted device can approve or reject.

+
+
+

Command line

+
pending-vouchers
+ + + +
+

REST

+

GET/account/{sessionId}/pending-vouchers

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/pending-vouchers'
+
+
+

Response 200

+
+
Response body
+
{
+  pendingVouchers: unknown[]
+}
+
Example
{
+  "pendingVouchers": [
+    {}
+  ]
+}
+ + + + +
pendingVouchersunknown[]EdgePendingVoucher[]: voucherId, activates, created, deviceDescription, ipDescription.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Approve a voucher.

+
approve-vouchersrc/cli/engine/routes/vouchers.ts
+
+

coreaccount.approveVoucher

+

Lets the waiting device finish logging in.

+
+
+

Command line

+
approve-voucher --voucher-id=<voucherId>
+ + + +
+

REST

+

POST/account/{sessionId}/approve-voucher

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  voucherId: string
+}
+
Example
{
+  "voucherId": "FS8xJ2kQ…"
+}
+ + + + +
voucherIdstringFrom pending-vouchers, or an OTP_REQUIRED error’s details.voucherId.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"voucherId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/approve-voucher'
+
+ + +
+
+

Reject a voucher.

+
reject-vouchersrc/cli/engine/routes/vouchers.ts
+
+

coreaccount.rejectVoucher

+

Denies the waiting device. The login it was issued for cannot complete.

+
+
+

Command line

+
reject-voucher --voucher-id=<voucherId>
+ + + +
+

REST

+

POST/account/{sessionId}/reject-voucher

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  voucherId: string
+}
+
Example
{
+  "voucherId": "FS8xJ2kQ…"
+}
+ + + + +
voucherIdstringFrom pending-vouchers, or an OTP_REQUIRED error’s details.voucherId.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"voucherId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/reject-voucher'
+
+ + +

Approving a login

+

The other side of request-edge-login: a logged-in account inspecting and approving a login somebody scanned.

+
+
+
+

Inspect a login request.

+
fetch-lobbysrc/cli/engine/routes/lobby.ts
+
+

coreaccount.fetchLobby

+

The other side of request-edge-login: shows who is asking, so a human can decide before approving.

+
+
+

Command line

+
fetch-lobby <lobbyId>
+ + + +
+

REST

+

GET/account/{sessionId}/fetch-lobby/{lobbyId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
lobbyIdstringFrom the QR code, or an edge://edge/<lobbyId> link.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/fetch-lobby/$LOBBYID?lobbyId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  lobbyId: string
+  loginRequest: {
+    appId: string;
+    displayName: string;
+    displayImageDarkUrl: string | null;
+    displayImageLightUrl: string | null
+  } | null
+}
+
Example
{
+  "lobbyId": "FS8xJ2kQ…",
+  "loginRequest": {}
+}
+ + + + + + + + + +
lobbyIdstringThe lobby that was fetched, echoed back.
loginRequest{ appId: string; displayName: string; displayImageDarkUrl: string | null; displayImageLightUrl: string | null; } | nullNull when the lobby carries no pending login request.
+
+
Errors

400BAD_REQUEST 503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Approve a login request.

+
approve-login-requestsrc/cli/engine/routes/lobby.ts
+
+

coreEdgeLoginRequest.approve Reached through account.fetchLobby(lobbyId).loginRequest.

Differs from core:

  • lobbyId — Core calls approve() on a request object. Over HTTP there is no object to hold, so the lobby names which one to approve.
+

Grants the requesting device access to this account.

+
+
+

Command line

+
approve-login-request <lobbyId>
+ + + +
+

REST

+

POST/account/{sessionId}/approve-login-request/{lobbyId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
lobbyIdstringFrom the QR code, or an edge://edge/<lobbyId> link.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"lobbyId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/approve-login-request/$LOBBYID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+}
+
Example
{
+  "ok": true
+}
+ + + + +
okbooleanAlways true; a failure arrives as an error envelope.
+
+
Errors

404NO_LOGIN_REQUEST 400BAD_REQUEST 503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The lobby is re-fetched on approve, so a request that expired between inspecting and approving fails with 404 NO_LOGIN_REQUEST.
+

Keys

+

Raw key infrastructure beneath the wallet API. Several of these return private key material, and the engine has no transport auth — treat any process that can reach the socket as fully trusted.

+
+
+
+

List every key in the account.

+
all-keyssrc/cli/engine/routes/keys.ts
+
+

coreaccount.allKeys

+

Includes archived and deleted keys, unlike currency-wallets.

+
+
+

Command line

+
all-keys
+ + + +
+

REST

+

GET/account/{sessionId}/all-keys

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/all-keys'
+
+
+

Response 200

+
+
Response body
+
{
+  allKeys: unknown[]
+}
+
Example
{
+  "allKeys": [
+    {}
+  ]
+}
+ + + + +
allKeysunknown[]EdgeWalletInfoFull[]: id, type, keys, archived, deleted, hidden, sortIndex.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Create a wallet from raw key JSON.

+
create-walletsrc/cli/engine/routes/keys.ts
+
+

coreaccount.createWallet

+

The import path. Use create-currency-wallet to make a fresh wallet with generated keys.

+
+
+

Command line

+
create-wallet --type=<type> [--keys='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/create-wallet

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  type: string
+  keys?: {
+    [keys: string]: unknown
+  }
+}
+
Example
{
+  "type": "string",
+  "keys": {}
+}
+ + + + + + + + + +
typestringWallet type, e.g. wallet:bitcoin.
keys{ [keys: string]: unknown; } optionalPlugin key material. Omit to let core generate it.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"type":"string","keys":{}}' \
+  'http://localhost/account/$SESS/create-wallet'
+
+
+

Response 200

+
+
Response body
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe new wallet. Its keys are already saved.
+
+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Read one wallet's key info.

+
get-wallet-infosrc/cli/engine/routes/keys.ts
+
+

coreaccount.getWalletInfo

+ +
+

Command line

+
get-wallet-info --id=<id>
+ + + +
+

REST

+

GET/account/{sessionId}/get-wallet-info

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  id: string
+}
+
Example
{
+  "id": "FS8xJ2kQ…"
+}
+ + + + +
idstringThe key id, from all-keys. Base64, like a wallet id.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-wallet-info?id=…'
+
+
+

Response 200

+

EdgeWalletInfoFull, verbatim from core — including the keys object.

+
unknown
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • An exact lookup: unlike the wallet-scoped routes this does not accept an id prefix.
+
+
+

Read raw private key material.

+
get-raw-private-keysrc/cli/engine/routes/keys.ts
+
+

coreaccount.getRawPrivateKey

+

Secret. Whatever the plugin stores — seed, mnemonic, xpriv.

+
+
+

Command line

+
get-raw-private-key --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/get-raw-private-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-raw-private-key?walletId=…'
+
+
+

Response 200

+

The plugin’s key object, at the top level.

+
unknown
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Read raw public key material.

+
get-raw-public-keysrc/cli/engine/routes/keys.ts
+
+

coreaccount.getRawPublicKey

+ +
+

Command line

+
get-raw-public-key --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/get-raw-public-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-raw-public-key?walletId=…'
+
+
+

Response 200

+

The plugin’s public key object.

+
unknown
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Export the private key for display.

+
get-display-private-keysrc/cli/engine/routes/keys.ts
+
+

coreaccount.getDisplayPrivateKey

+

Secret. The human-facing form — WIF, seed phrase, whatever the plugin shows on its export screen.

+
+
+

Command line

+
get-display-private-key --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/get-display-private-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-display-private-key?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  key: string
+}
+
Example
{
+  "key": "string"
+}
+ + + + +
keystringThe displayable private key.
+
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Export the public key for display.

+
get-display-public-keysrc/cli/engine/routes/keys.ts
+
+

coreaccount.getDisplayPublicKey

+

The xpub or equivalent — safe to share for watch-only use.

+
+
+

Command line

+
get-display-public-key --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/get-display-public-key

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-display-public-key?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  key: string
+}
+
Example
{
+  "key": "string"
+}
+ + + + +
keystringThe displayable public key.
+
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

List chains a wallet can split into.

+
list-splittable-wallet-typessrc/cli/engine/routes/keys.ts
+
+

coreaccount.listSplittableWalletTypes

+

Forked-chain support: which wallet types can be derived from these keys.

+
+
+

Command line

+
list-splittable-wallet-types --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/list-splittable-wallet-types

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/list-splittable-wallet-types?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  walletTypes: string[]
+}
+
Example
{
+  "walletTypes": [
+    "string"
+  ]
+}
+ + + + +
walletTypesstring[]Types valid for split.
+
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Archive, delete, hide, or reorder wallets.

+
change-wallet-statessrc/cli/engine/routes/keys.ts
+
+

coreaccount.changeWalletStates

+

The canonical backend for every wallet flag; there are no separate archive, unarchive or undelete verbs.

+
+
+

Command line

+
change-wallet-states [--wallet-states='<json>'] --wallet-id=<value> [--archived=<value>] [--deleted=<value>] [--hidden=<value>] [--sort-index=<value>]
+ +
Client-only flags
--wallet-idrequiredThe wallet to change. The command makes it the key of a single-entry walletStates map.
--archivedoptionalHide from the active list.
--deletedoptionalMark deleted.
--hiddenoptionalHide from the wallet picker.
--sort-indexoptionalPosition in the wallet list.
+

The command builds a single-wallet walletStates map from these flags, and needs at least one.

+
+
+

REST

+

POST/account/{sessionId}/change-wallet-states

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletStates?: {
+    [keys: string]: {
+      archived: boolean | undefined;
+      deleted: boolean | undefined;
+      hidden: boolean | undefined;
+      sortIndex: number | undefined
+    }
+  }
+}
+
Example
{
+  "walletStates": {}
+}
+ + + + +
walletStates{ [keys: string]: { archived: boolean | undefined; deleted: boolean | undefined; hidden: boolean | undefined; sortIndex: number | undefined; }; } optionalEdgeWalletStates: wallet ids to the flags being changed.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletStates":{}}' \
+  'http://localhost/account/$SESS/change-wallet-states'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Swap quotes

+

Cross-asset exchange. Quotes are live objects held server-side under a swap_ handle, so approving one means naming its objectId rather than re-uploading the quote.

+
+
+
+

Fetch swap quotes.

+
fetch-swap-quotessrc/cli/engine/routes/swap.ts
+
+

coreaccount.fetchSwapQuotes

Differs from core:

  • fromWalletId — Core takes the wallet object; over HTTP it is an id.
  • toWalletId — Core takes the wallet object; over HTTP it is an id.
+

Polls every enabled swap plugin and parks each result under its own swap_ handle with a 5 minute TTL.

+
+
+

Command line

+
fetch-swap-quotes --from-wallet-id=<fromWalletId> --to-wallet-id=<toWalletId> --native-amount=<nativeAmount> [--from-token-id=<fromTokenId>] [--to-token-id=<toTokenId>] [--quote-for=from|max|to] [--plugin-id=<preferPluginId>]
+ + + +
+

REST

+

POST/account/{sessionId}/fetch-swap-quotes

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  fromWalletId: string
+  toWalletId: string
+  nativeAmount: string
+  fromTokenId?: string | null
+  toTokenId?: string | null
+  quoteFor?: "from" | "max" | "to"
+  preferPluginId?: string
+}
+
Example
{
+  "fromWalletId": "FS8xJ2kQ…",
+  "toWalletId": "FS8xJ2kQ…",
+  "nativeAmount": "12345",
+  "fromTokenId": "FS8xJ2kQ…",
+  "toTokenId": "FS8xJ2kQ…",
+  "quoteFor": "<\"from\">",
+  "preferPluginId": "FS8xJ2kQ…"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
fromWalletIdstringSource wallet. Accepts a unique prefix.
toWalletIdstringDestination wallet.
nativeAmountstringHow much, in native units.
fromTokenIdstring | null optionalDefaults to the native asset.
toTokenIdstring | null optionalDefaults to the native asset.
quoteFor"from" | "max" | "to" optionalfrom spends this much of the source, to receives this much at the destination, max sends everything. Defaults to from.
preferPluginIdstring optionalRestrict to one exchange.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"fromWalletId":"FS8xJ2kQ…","toWalletId":"FS8xJ2kQ…","nativeAmount":"12345","fromTokenId":"FS8xJ2kQ…","toTokenId":"FS8xJ2kQ…","quoteFor":"<\"from\">","preferPluginId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/fetch-swap-quotes'
+
+
+

Response 200

+
+
Response body
+
{
+  quoteCount: number
+  quotes: {
+    objectId: string;
+    kind: string;
+    expiresAt: string;
+    pluginId: string;
+    isEstimate: boolean;
+    canBePartial: boolean | null;
+    maxFulfillmentSeconds: number | null;
+    minReceiveAmount: string | null;
+    fromNativeAmount: string;
+    toNativeAmount: string;
+    networkFee: {
+      nativeAmount: string;
+      tokenId: string | null
+    };
+    quoteExpirationDate: string | null;
+    swapInfo: {
+      pluginId: string;
+      displayName: string;
+      supportEmail: string;
+      isDex: boolean | null
+    };
+    request: {
+      fromTokenId: string | null;
+      toTokenId: string | null;
+      nativeAmount: string;
+      quoteFor: "from" | "to" | "max";
+      fromWalletId: string;
+      toWalletId: string
+    }
+  }[]
+}
+
Example
{
+  "quoteCount": 1,
+  "quotes": [
+    {}
+  ]
+}
+ + + + + + + + + +
quoteCountnumberHow many plugins answered.
quotes{ objectId: string; kind: string; expiresAt: string; pluginId: string; isEstimate: boolean; canBePartial: boolean | null; maxFulfillmentSeconds: number | null; minReceiveAmount: string | null; fromNativeAmount: string; toNativeAmount: string; networkFee: { nativeAmount: string; tokenId: string | null; }; quoteExpirationDate: string | null; swapInfo: { pluginId: string; displayName: string; supportEmail: string; isDex: boolean | null; }; request: { fromTokenId: string | null; toTokenId: string | null; nativeAmount: string; quoteFor: "from" | "to" | "max"; fromWalletId: string; toWalletId: string; }; }[]One quote per plugin that answered, each already parked under its own handle. Plugins that failed or had nothing to offer are simply absent.
+
+
Errors

400BAD_REQUEST 422SWAP_BELOW_LIMIT 422SWAP_ABOVE_LIMIT 422SWAP_CURRENCY 403SWAP_PERMISSION 422SWAP_ADDRESS 400SAME_CURRENCY 422INSUFFICIENT_FUNDS 404WALLET_NOT_FOUND 503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Every returned quote holds an open plugin object. Approving one releases only that handle; close the rest, or let them expire.
  • An empty quotes array with quoteCount: 0 is a success, not an error — no plugin could serve the pair.
+
+
+

Re-read a quote.

+
swap-quote-getsrc/cli/engine/routes/swap.ts
+
+

coreEngine handle store; the quote is a live EdgeSwapQuote held server-side.

+ +
+

Command line

+
swap-quote-get <objectId>
+ + + +
+

REST

+

GET/account/{sessionId}/swap-quote/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/swap-quote/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  pluginId: string
+  isEstimate: boolean
+  canBePartial: boolean | null
+  maxFulfillmentSeconds: number | null
+  minReceiveAmount: string | null
+  fromNativeAmount: string
+  toNativeAmount: string
+  networkFee: {
+    nativeAmount: string;
+    tokenId: string | null
+  }
+  quoteExpirationDate: string | null
+  swapInfo: {
+    pluginId: string;
+    displayName: string;
+    supportEmail: string;
+    isDex: boolean | null
+  }
+  request: {
+    fromTokenId: string | null;
+    toTokenId: string | null;
+    nativeAmount: string;
+    quoteFor: "from" | "to" | "max";
+    fromWalletId: string;
+    toWalletId: string
+  }
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "pluginId": "FS8xJ2kQ…",
+  "isEstimate": true,
+  "canBePartial": true,
+  "maxFulfillmentSeconds": 1,
+  "minReceiveAmount": "12345",
+  "fromNativeAmount": "12345",
+  "toNativeAmount": "12345",
+  "networkFee": {},
+  "quoteExpirationDate": "2026-09-02T16:35:00.000Z",
+  "swapInfo": {},
+  "request": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
pluginIdstringSwap provider that produced this quote.
isEstimatebooleanTrue when the provider may settle at a different rate than quoted.
canBePartialboolean | nullTrue when the provider may fill only part of the order. Null when it does not say.
maxFulfillmentSecondsnumber | nullLongest the provider expects a partial fill to take.
minReceiveAmountstring | nullLeast the provider guarantees to deliver, in the destination’s native units.
fromNativeAmountstringAmount leaving the source wallet.
toNativeAmountstringAmount arriving in the destination wallet.
networkFee{ nativeAmount: string; tokenId: string | null; }On-chain fee for the sending transaction. It is not the provider’s own spread, which is already in the rate.
quoteExpirationDatestring | nullWhen the provider stops honouring the rate. Null when it does not expire.
swapInfo{ pluginId: string; displayName: string; supportEmail: string; isDex: boolean | null; }EdgeSwapInfo: how to name the provider and where to send complaints.
request{ fromTokenId: string | null; toTokenId: string | null; nativeAmount: string; quoteFor: "from" | "to" | "max"; fromWalletId: string; toWalletId: string; }The EdgeSwapRequest this quote answers, echoed back so quotes from different plugins can be compared without tracking what was asked.
+
+
Errors

404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Check quoteExpirationDate as well as expiresAt: the plugin's price can go stale before the handle does.
+
+
+

Execute a quote.

+
approve-swap-quotesrc/cli/engine/routes/swap.ts
+
+

coreEdgeSwapQuote.approve

+

Moves funds. The handle is released afterwards whether or not the response is read, so record orderId from it.

+
+
+

Command line

+
approve-swap-quote <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/swap-quote/approve/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/swap-quote/approve/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: unknown
+  objectId: string
+  orderId: unknown
+  destinationAddress: unknown
+  transaction: unknown
+}
+
Example
{
+  "ok": {},
+  "objectId": "FS8xJ2kQ…",
+  "orderId": {},
+  "destinationAddress": {},
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + +
okunknownTrue once the swap is submitted and the send broadcast.
objectIdstringThe handle that was consumed.
orderIdunknownThe exchange’s order reference, when it gives one.
destinationAddressunknownAddress the funds were sent to, when the exchange reports one.
transactionunknownThe on-chain send to the exchange.
+
+
Errors

404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_SESSION_MISMATCH 409OBJECT_IN_USE 422INSUFFICIENT_FUNDS 503NETWORK_ERROR 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • A failure releases the handle too, and a retry answers OBJECT_NOT_FOUND. approve() signs, broadcasts and then does its own bookkeeping, and from outside the plugin an error after the money moved cannot be told from one before it — so a retry has to start from a fresh quote rather than risk a second broadcast.
  • The plugin attaches its own savedAction and assetAction metadata; the engine adds none.
+
+
+

Discard a quote.

+
close-swap-quotesrc/cli/engine/routes/swap.ts
+
+

coreEdgeSwapQuote.close

+

Closes the plugin object without executing, freeing whatever the exchange was holding.

+
+
+

Command line

+
close-swap-quote <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/swap-quote/close/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/swap-quote/close/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+  objectId: string
+}
+
Example
{
+  "ok": true,
+  "objectId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
okbooleanAlways true; a failure arrives as an error envelope.
objectIdstringThe handle this call consumed. It is now expired.
+
+
Errors

404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Data store

+

The account’s synced key-value store, where plugins keep their own state. One route per EdgeDataStore method.

+
+
+
+

List data-store ids.

+
list-store-idssrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.listStoreIds

+

The account's synced key-value store, where plugins keep their own state.

+
+
+

Command line

+
list-store-ids
+ + + +
+

REST

+

GET/account/{sessionId}/list-store-ids

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/list-store-ids'
+
+
+

Response 200

+
+
Response body
+
{
+  storeIds: string[]
+}
+
Example
{
+  "storeIds": [
+    "string"
+  ]
+}
+ + + + +
storeIdsstring[]Every store holding at least one item.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

List item ids in a store.

+
list-item-idssrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.listItemIds

+ +
+

Command line

+
list-item-ids --store-id=<storeId>
+ + + +
+

REST

+

GET/account/{sessionId}/list-item-ids

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  storeId: string
+}
+
Example
{
+  "storeId": "FS8xJ2kQ…"
+}
+ + + + +
storeIdstringPlugin or app namespace within the account data store.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/list-item-ids?storeId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  itemIds: string[]
+}
+
Example
{
+  "itemIds": [
+    "string"
+  ]
+}
+ + + + +
itemIdsstring[]Keys in this store. Empty if it has none.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Read an item.

+
get-itemsrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.getItem

+

Values are opaque strings; encoding is the caller's business.

+
+
+

Command line

+
get-item --store-id=<storeId> --item-id=<itemId>
+ + + +
+

REST

+

GET/account/{sessionId}/get-item

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  storeId: string
+  itemId: string
+}
+
Example
{
+  "storeId": "FS8xJ2kQ…",
+  "itemId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
storeIdstringPlugin or app namespace within the account data store.
itemIdstringKey within the store.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/get-item?storeId=…&itemId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  value: string
+}
+
Example
{
+  "value": "string"
+}
+ + + + +
valuestringThe stored string.
+
+
Errors

404NOT_FOUND 400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Write an item.

+
set-itemsrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.setItem

+

Creates the store if it does not exist.

+
+
+

Command line

+
set-item --store-id=<storeId> --item-id=<itemId> --value=<value>
+ + + +
+

REST

+

POST/account/{sessionId}/set-item

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  storeId: string
+  itemId: string
+  value: string
+}
+
Example
{
+  "storeId": "FS8xJ2kQ…",
+  "itemId": "FS8xJ2kQ…",
+  "value": "string"
+}
+ + + + + + + + + + + + + + +
storeIdstringPlugin or app namespace within the account data store.
itemIdstringKey within the store.
valuestringThe string to store.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"storeId":"FS8xJ2kQ…","itemId":"FS8xJ2kQ…","value":"string"}' \
+  'http://localhost/account/$SESS/set-item'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Delete an item.

+
delete-itemsrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.deleteItem

+ +
+

Command line

+
delete-item --store-id=<storeId> --item-id=<itemId>
+ + + +
+

REST

+

POST/account/{sessionId}/delete-item

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  storeId: string
+  itemId: string
+}
+
Example
{
+  "storeId": "FS8xJ2kQ…",
+  "itemId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
storeIdstringPlugin or app namespace within the account data store.
itemIdstringKey within the store.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"storeId":"FS8xJ2kQ…","itemId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/delete-item'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Delete an entire store.

+
delete-storesrc/cli/engine/routes/dataStore.ts
+
+

coreaccount.dataStore.deleteStore

+

Removes every item in it, which cannot be undone from this API.

+
+
+

Command line

+
delete-store --store-id=<storeId>
+ + + +
+

REST

+

POST/account/{sessionId}/delete-store

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  storeId: string
+}
+
Example
{
+  "storeId": "FS8xJ2kQ…"
+}
+ + + + +
storeIdstringPlugin or app namespace within the account data store.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"storeId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/delete-store'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Wallet

+

Calls on a single EdgeCurrencyWallet. Each names its wallet with --wallet-id, which accepts a full id or any unique prefix.

+
+

Wallet state

+

Account-level wallet listing and creation, then per-wallet calls. A {walletId} segment accepts a unique prefix, so those routes can also return 404 WALLET_NOT_FOUND or 409 AMBIGUOUS_WALLET_ID.

+
+
+
+

Wallet detail.

+
wallet-infosrc/cli/engine/routes/wallets.ts
+
+

coreEngine composite of EdgeCurrencyWallet properties plus its EdgeCurrencyConfig token map.

+ +
+

Command line

+
wallet-info --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/wallet

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet?walletId=…'
+
+
+

Response 200

+

Every WalletSummary field, plus denominations and walletSettings. allTokens is not here — wallet-tokens exists to carry it, and returning it from both sent the same map twice in a session that calls both.

+
unknown
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Rename a wallet.

+
rename-walletsrc/cli/engine/routes/wallets.ts
+
+

corewallet.renameWallet

+ +
+

Command line

+
rename-wallet --wallet-id=<walletId> --name=<name>
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/rename-wallet

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  name: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "name": "string"
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
namestringThe new display name.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","name":"string"}' \
+  'http://localhost/account/$SESS/wallet/rename-wallet'
+
+ + +
+
+

Change a wallet's fiat currency.

+
set-fiat-currency-codesrc/cli/engine/routes/wallets.ts
+
+

corewallet.setFiatCurrencyCode

+

Affects how balances and history are priced, not the asset itself.

+
+
+

Command line

+
set-fiat-currency-code --wallet-id=<walletId> --fiat-currency-code=<fiatCurrencyCode>
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/set-fiat-currency-code

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  fiatCurrencyCode: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "fiatCurrencyCode": "string"
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
fiatCurrencyCodestringe.g. iso:EUR.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","fiatCurrencyCode":"string"}' \
+  'http://localhost/account/$SESS/wallet/set-fiat-currency-code'
+
+ + +
+
+

Pause or resume a wallet engine.

+
change-pausedsrc/cli/engine/routes/wallets.ts
+
+

corewallet.changePaused

+

A paused wallet stops syncing, which is how a caller quiets a chain it does not currently care about.

+
+
+

Command line

+
change-paused --wallet-id=<walletId> --paused=true|false
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/change-paused

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  paused: boolean
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "paused": true
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
pausedbooleanTrue to stop syncing.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","paused":true}' \
+  'http://localhost/account/$SESS/wallet/change-paused'
+
+ + +
+
+

Nudge one wallet to sync.

+
wallet-syncsrc/cli/engine/routes/wallets.ts
+
+

corewallet.sync

+ +
+

Command line

+
wallet-sync --wallet-id=<walletId>
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/sync

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/wallet/sync'
+
+ +

Notes

  • Named wallet-sync on the CLI because sync is account.sync.
+
+
+

Rescan the blockchain from scratch.

+
resync-blockchainsrc/cli/engine/routes/wallets.ts
+
+

corewallet.resyncBlockchain

+

Drops cached chain state and re-scans. Expensive, and the wallet reports an incomplete balance until it finishes.

+
+
+

Command line

+
resync-blockchain --wallet-id=<walletId>
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/resync-blockchain

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/wallet/resync-blockchain'
+
+ +

Notes

  • Returns when the resync is requested, not when it completes. Watch syncRatio for progress.
+
+
+

Split a wallet into another chain.

+
splitsrc/cli/engine/routes/wallets.ts
+
+

corewallet.split

+

Forked-chain support: derive a wallet of a different type from the same keys. list-splittable-wallet-types says which are valid.

+
+
+

Command line

+
split --wallet-id=<walletId> --split-wallets='<json>'
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/split

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  splitWallets: {
+    walletType: string;
+    name: string | undefined;
+    fiatCurrencyCode: string | undefined
+  }[]
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "splitWallets": [
+    {}
+  ]
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
splitWallets{ walletType: string; name: string | undefined; fiatCurrencyCode: string | undefined; }[]EdgeSplitCurrencyWallet[]: walletType, plus optional name and fiatCurrencyCode.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","splitWallets":[{}]}' \
+  'http://localhost/account/$SESS/wallet/split'
+
+
+

Response 200

+
+
Response body
+
{
+  results: unknown[]
+}
+
Example
{
+  "results": [
+    {}
+  ]
+}
+ + + + +
resultsunknown[]Per-entry outcomes, like batch create.
+
+
Errors

400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Dump wallet engine state.

+
dump-datasrc/cli/engine/routes/wallets.ts
+
+

corewallet.dumpData

+

Plugin-defined debug output. Shape varies by plugin and can be very large.

+
+
+

Command line

+
dump-data --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/wallet/dump-data

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/dump-data?walletId=…'
+
+
+

Response 200

+

EdgeDataDump, straight from the plugin.

+
unknown
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Balances for every asset in the wallet.

+
balance-mapsrc/cli/engine/routes/wallets.ts
+
+

corewallet.balanceMap Rendered as an array, with currencyCode and displayAmount added from the wallet's denominations.

+

The native currency plus every enabled token.

+
+
+

Command line

+
balance-map --wallet-id=<walletId> [--token-id=<value>]
+ +
Client-only flags
--token-idoptionalClient-side filter; core has no single-balance accessor.
+ +
+

REST

+

GET/account/{sessionId}/wallet/balance-map

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/balance-map?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  balances: {
+    tokenId: string | null;
+    currencyCode: string;
+    nativeAmount: string;
+    displayAmount: string | null;
+    unknownToken: boolean
+  }[]
+}
+
Example
{
+  "balances": [
+    {}
+  ]
+}
+ + + + +
balances{ tokenId: string | null; currencyCode: string; nativeAmount: string; displayAmount: string | null; unknownToken: boolean; }[]One entry per asset the wallet holds, native coin first: tokenId, currencyCode, nativeAmount, displayAmount and unknownToken. A token the plugin reports a balance for but whose config it no longer carries is listed with unknownToken: true and a null displayAmount, rather than failing the whole wallet.
+
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • On the CLI, omit --token-id for the native asset rather than passing the literal null.
+
+
+

Receive addresses.

+
get-addressessrc/cli/engine/routes/wallets.ts
+
+

corewallet.getAddresses

+ +
+

Command line

+
get-addresses --wallet-id=<walletId> [--token-id=<tokenId>] [--force-index=<forceIndex>]
+ + + +
+

REST

+

GET/account/{sessionId}/wallet/get-addresses

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+  tokenId?: string | null
+  forceIndex?: number
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "forceIndex": 0
+}
+ + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
tokenIdstring | null optionalDefaults to the native asset.
forceIndexnumber optionalDerive at a specific index.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/get-addresses?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  addresses: unknown[]
+}
+
Example
{
+  "addresses": [
+    {}
+  ]
+}
+ + + + +
addressesunknown[]EdgeAddress[]: addressType, publicAddress, nativeBalance.
+
+
Errors

404TOKEN_NOT_FOUND 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Tokens

+

Which tokens a wallet tracks. Enabled tokens are the ones it syncs balances for; detected ones were seen on-chain but are not yet enabled.

+
+
+
+

List a wallet's tokens.

+
wallet-tokenssrc/cli/engine/routes/tokens.ts
+
+

coreEngine composite of the EdgeCurrencyConfig token maps plus wallet.enabledTokenIds and wallet.detectedTokenIds.

+

"Enabled" tokens are the ones the wallet syncs balances for; "detected" ones were seen on-chain but are not yet enabled.

+
+
+

Command line

+
wallet-tokens --wallet-id=<walletId>
+ + + +
+

REST

+

GET/account/{sessionId}/wallet/tokens

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/tokens?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  allTokens: {
+    [keys: string]: unknown
+  }
+  customTokens: {
+    [keys: string]: unknown
+  }
+  enabledTokenIds: string[]
+  detectedTokenIds: string[]
+}
+
Example
{
+  "allTokens": {},
+  "customTokens": {},
+  "enabledTokenIds": [
+    "string"
+  ],
+  "detectedTokenIds": [
+    "string"
+  ]
+}
+ + + + + + + + + + + + + + + + + + + +
allTokens{ [keys: string]: unknown; }EdgeToken by tokenId: everything the plugin ships with, plus this account’s own. builtinTokens is not returned separately — it is this map minus customTokens, and sending both wrote the whole built-in list down the socket twice.
customTokens{ [keys: string]: unknown; }EdgeToken by tokenId: tokens this account added by hand.
enabledTokenIdsstring[]Which of the above the wallet is actually tracking.
detectedTokenIdsstring[]Seen on-chain but not enabled, so their balances are not synced.
+
+
Errors

404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Set the enabled token set.

+
change-enabled-token-idssrc/cli/engine/routes/tokens.ts
+
+

corewallet.changeEnabledTokenIds

+

Absolute: anything missing from tokenIds is disabled. Core has only this setter, so there is no add or remove call.

+
+
+

Command line

+
change-enabled-token-ids --wallet-id=<walletId> --token-ids='<json>' [--add=<value>] [--remove=<value>]
+ +
Client-only flags
--addoptionalRead the current set, add this id, write it back.
--removeoptionalRead the current set, drop this id, write it back.
+ +
+

REST

+

POST/account/{sessionId}/wallet/change-enabled-token-ids

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  tokenIds: string[]
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "tokenIds": [
+    "string"
+  ]
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
tokenIdsstring[]The complete desired set.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","tokenIds":["string"]}' \
+  'http://localhost/account/$SESS/wallet/change-enabled-token-ids'
+
+
+

Response 200

+
+
Response body
+
{
+  enabledTokenIds: string[]
+}
+
Example
{
+  "enabledTokenIds": [
+    "string"
+  ]
+}
+ + + + +
enabledTokenIdsstring[]The wallet’s enabled tokens after the change, not just what changed.
+
+
Errors

400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The command's --add and --remove are client-side sugar over this one route, and cost an extra read first.
+

Transactions

+

Reading transaction history, exporting it, and editing its metadata.

+
+
+
+

List or export a wallet's transactions.

+
get-transactionssrc/cli/engine/routes/transactions.ts
+
+

corewallet.getTransactions

Differs from core:

  • limit — Engine-side paging; core returns every match.
  • offset — Engine-side paging; core returns every match.
  • fiat — Selects the currency the engine values each transaction in.
  • exportFormat — Engine-side rendering to CSV, QBO or Bitwave.
  • bitwaveAccountId — Required by the Bitwave export format.
  • saveExportPrefs — Opt-in write of the wallet’s synced exportTxInfo.json, the record the GUI export scene reads back.
+

Reads history, overlays the display metadata the GUI shows, fills historical fiat, and optionally formats the result — all on this one call.

+
+
+

Command line

+
get-transactions --wallet-id=<walletId> [--token-id=<tokenId>] [--limit=<limit>] [--offset=<offset>] [--save-export-prefs[=false]] [--start-date=<startDate>] [--end-date=<endDate>] [--search-string=<searchString>] [--spam-threshold=<spamThreshold>] [--fiat=<fiat>] [--export-format=<exportFormat>] [--bitwave-account=<bitwaveAccountId>] [--out=<value>]
+ +
Client-only flags
--outoptionalWhere to write the returned files. One format: the path. Several: a stem, plus .csv / .qbo / .bitwave.csv.
+ +
+

REST

+

GET/account/{sessionId}/wallet/get-transactions

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+  tokenId?: string | null
+  limit?: number
+  offset?: number
+  saveExportPrefs?: boolean
+  startDate?: Date
+  endDate?: Date
+  searchString?: string
+  spamThreshold?: string
+  fiat?: string
+  exportFormat?: string
+  bitwaveAccountId?: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "limit": 0,
+  "offset": 0,
+  "saveExportPrefs": true,
+  "startDate": "<Date>",
+  "endDate": "<Date>",
+  "searchString": "string",
+  "spamThreshold": "string",
+  "fiat": "2026-09-02T16:35:00.000Z",
+  "exportFormat": "2026-09-02T16:35:00.000Z",
+  "bitwaveAccountId": "FS8xJ2kQ…"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
tokenIdstring | null optionalDefaults to the native asset.
limitnumber optionalHow many to return. Defaults to 100; pass 0 for every transaction from offset on. total in the response says how many matched, so a caller can page with offset.
offsetnumber optionalWhere to start. Defaults to 0.
saveExportPrefsboolean optionalSave bitwaveAccountId and the chosen formats into the wallet’s synced exportTxInfo.json, which the GUI export scene reads back. Off by default: a read does not change saved preferences.
startDateDate optionalISO-8601, or epoch milliseconds.
endDateDate optionalISO-8601, or epoch milliseconds.
searchStringstring optionalMatches payee, category, notes and txid.
spamThresholdstring optionalNative-amount floor. Omitted, the account spam-filter setting applies; a value always overrides it, and 0 shows everything. An empty value reads as omitted, like every other query parameter.
fiatstring optionalThree-letter ISO 4217 code. Defaults to the account defaultIsoFiat.
exportFormatstring optionalComma list of csv, qbo, bitwave.
bitwaveAccountIdstring optionalA 400 unless exportFormat includes bitwave.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/get-transactions?walletId=…'
+
+
+

Response 200

+

{ transactions, total, isoFiat }, or { ok, isoFiat, total, files } when exportFormat is set. Each transaction carries metadata.name, metadata.category and metadata.notes as localized prose in the engine’s boot locale — the engine has no per-request locale, so a second shell with a different LANG reuses the first engine and gets its language. displayInfo beside them carries the machine values the prose was derived from (direction, assetActionType, actionType, and the category split whose category member is one of transfer, exchange, expense, income), so a caller can render its own text.

+
unknown
+
Errors

400BAD_REQUEST 400MISSING_BITWAVE_ACCOUNT_ID 404TOKEN_NOT_FOUND 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • The metadata overlay and the fiat fill are response-only. Neither writes to disk.
  • limit and offset apply before the fiat fill, so a large page asks the rates server about more dates. Unpriced dates are batched into one request per 100, and every rate is cached for the life of the engine, so a second listing of the same range needs none.
  • This is the one GET that can write: passing bitwaveAccountId persists it to exportTxInfo.json on the wallet disklet.
+
+
+

Count transactions in a wallet.

+
get-num-transactionssrc/cli/engine/routes/transactions.ts
+
+

corewallet.getNumTransactions

+

Cheaper than listing when only the total matters.

+
+
+

Command line

+
get-num-transactions --wallet-id=<walletId> [--token-id=<tokenId>]
+ + + +
+

REST

+

GET/account/{sessionId}/wallet/get-num-transactions

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+  tokenId?: string | null
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
tokenIdstring | null optionalDefaults to the native asset.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/get-num-transactions?walletId=…'
+
+
+

Response 200

+
+
Response body
+
{
+  numTransactions: number
+}
+
Example
{
+  "numTransactions": 0
+}
+ + + + +
numTransactionsnumberEvery transaction the wallet knows of.
+
+
Errors

404TOKEN_NOT_FOUND 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Unfiltered: spamThreshold, dates and searchString do not apply, so this can exceed total from get-transactions.
+
+
+

Save transaction metadata.

+
save-tx-metadatasrc/cli/engine/routes/transactions.ts
+
+

corewallet.saveTxMetadata

+

One of the paths that write transaction metadata to disk. The others are save-tx-action, which writes savedAction and assetAction to the same file, and save-tx and spend, both of which re-apply the caller's metadata through saveTxAndMetadata after the transaction is saved.

+
+
+

Command line

+
save-tx-metadata --wallet-id=<walletId> --txid=<txid> [--token-id=<tokenId>] --metadata='<json>'
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/save-tx-metadata

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  txid: string
+  tokenId?: string | null
+  metadata: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeMetadataChange
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "txid": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "metadata": "<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeMetadataChange>"
+}
+ + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
txidstringWhich transaction to tag.
tokenIdstring | null optionalDefaults to the native asset.
metadataimport("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeMetadataChangeEdgeMetadataChange: name, category, notes, exchangeAmount, bizId. null on a field deletes it; omitting the field leaves it unchanged.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","txid":"FS8xJ2kQ…","tokenId":"FS8xJ2kQ…","metadata":"<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeMetadataChange>"}' \
+  'http://localhost/account/$SESS/wallet/save-tx-metadata'
+
+ +

Notes

  • metadata is an EdgeMetadataChange, so an explicit null clears a field while an omitted one is left alone.
+
+
+

Save a transaction action.

+
save-tx-actionsrc/cli/engine/routes/transactions.ts
+
+

corewallet.saveTxAction

+

Records what a transaction was — a swap, a stake — beyond its metadata.

+
+
+

Command line

+
save-tx-action --wallet-id=<walletId> --txid=<txid> [--token-id=<tokenId>] --saved-action='<json>' [--asset-action='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/save-tx-action

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  txid: string
+  tokenId?: string | null
+  savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionSwap | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionStake | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionFiat | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionTokenApproval | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionGiftCard
+  assetAction?: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "txid": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "savedAction": "<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionSwap>",
+  "assetAction": "<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeAssetAction>"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
txidstringWhich transaction to annotate.
tokenIdstring | null optionalDefaults to the native asset.
savedActionimport("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionSwap | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionStake | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionFiat | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionTokenApproval | import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxActionGiftCardEdgeTxAction describing what happened, discriminated on actionType: swap, stake, fiat, tokenApproval or giftCard.
assetActionimport("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction optionalEdgeAssetAction: one assetActionType.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","txid":"FS8xJ2kQ…","tokenId":"FS8xJ2kQ…","savedAction":"<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionSwap>","assetAction":"<import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeAssetAction>"}' \
+  'http://localhost/account/$SESS/wallet/save-tx-action'
+
+ +

Notes

  • When assetAction is omitted it defaults to { assetActionType: 'transfer' }.
+

Spending

+

Two ways to send funds. spend does the whole thing in one call; the staged workflow — make-spend, sign-tx, broadcast-tx, save-tx — hands back an object handle at each step so fees can be inspected before committing.

+
+
+
+

Largest sendable amount.

+
get-max-spendablesrc/cli/engine/routes/spend.ts
+
+

corewallet.getMaxSpendable

Differs from core:

  • to — Shorthand the engine expands into spendTargets, so a one-output send needs no nested JSON.
  • nativeAmount — Amount for the to shorthand, in the smallest unit.
  • amount — Alias of nativeAmount for the to shorthand: the chain’s smallest unit, not whole coins.
+

What empties the wallet after fees. A destination is still required, since fees depend on it.

+
+
+

Command line

+
get-max-spendable --wallet-id=<walletId> [--spend-info='<json>'] [--to=<to>] [--native-amount=<nativeAmount>] [--amount=<amount>] [--token-id=<tokenId>] [--metadata='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/get-max-spendable

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  spendInfo?: {
+    spendTargets: {
+      publicAddress: string | undefined;
+      nativeAmount: string | undefined;
+      uniqueIdentifier: string | undefined;
+      memo: string | undefined;
+      otherParams: {
+        [keys: string]: unknown
+      } | undefined
+    }[];
+    tokenId: string | null;
+    metadata: EdgeMetadata | undefined;
+    networkFeeOption: string | undefined;
+    customNetworkFee: {
+      [keys: string]: unknown
+    } | undefined;
+    rbfTxid: string | undefined;
+    memos: {
+      [keys: string]: unknown
+    }[] | undefined;
+    assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined;
+    savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined;
+    otherParams: {
+      [keys: string]: unknown
+    } | undefined
+  }
+  to?: string
+  nativeAmount?: string
+  amount?: string
+  tokenId?: string | null
+  metadata?: EdgeMetadata
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "spendInfo": {},
+  "to": "string",
+  "nativeAmount": "12345",
+  "amount": "12345",
+  "tokenId": "FS8xJ2kQ…",
+  "metadata": "<EdgeMetadata>"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
spendInfo{ spendTargets: { publicAddress: string | undefined; nativeAmount: string | undefined; uniqueIdentifier: string | undefined; memo: string | undefined; otherParams: { [keys: string]: unknown; } | undefined; }[]; tokenId: string | null; metadata: EdgeMetadata | undefined; networkFeeOption: string | undefined; customNetworkFee: { [keys: string]: unknown; } | undefined; rbfTxid: string | undefined; memos: { [keys: string]: unknown; }[] | undefined; assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined; savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined; otherParams: { [keys: string]: unknown; } | undefined; } optionalA full EdgeSpendInfo, used as-is when present. spendTargets is required.
tostring optionalAddress or BIP21 URI, run through wallet.parseUri.
nativeAmountstring optionalHow much, in native units.
amountstring optionalAlias of nativeAmount.
tokenIdstring | null optionalDefaults to the native asset.
metadataEdgeMetadata optionalWins over anything parsed out of the URI.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","spendInfo":{},"to":"string","nativeAmount":"12345","amount":"12345","tokenId":"FS8xJ2kQ…","metadata":"<EdgeMetadata>"}' \
+  'http://localhost/account/$SESS/wallet/get-max-spendable'
+
+
+

Response 200

+
+
Response body
+
{
+  nativeAmount: string
+}
+
Example
{
+  "nativeAmount": "12345"
+}
+ + + + +
nativeAmountstringThe most this wallet can send.
+
+
Errors

404TOKEN_NOT_FOUND 422INSUFFICIENT_FUNDS 400BAD_REQUEST 503NETWORK_ERROR 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Send funds.

+
spendsrc/cli/engine/routes/spend.ts
+
+

coreGUI composite: makeSpend, signTx, broadcastTx and saveTx together.

+

makeSpend, then signTx, then optionally broadcastTx and saveTx, in one request. broadcast and save both default to true, so a bare body with a destination and an amount moves real money. A completed spend leaves no handle behind.

+
+
+

Command line

+
spend [--use-max[=false]] [--dry-run[=false]] [--broadcast[=false]] [--save[=false]] --wallet-id=<walletId> [--spend-info='<json>'] [--to=<to>] [--native-amount=<nativeAmount>] [--amount=<amount>] [--token-id=<tokenId>] [--metadata='<json>']
+
spend-max — Send a wallet’s entire spendable balance.
spend-max [--dry-run[=false]] [--broadcast[=false]] [--save[=false]] --wallet-id=<walletId> [--spend-info='<json>'] [--to=<to>] [--native-amount=<nativeAmount>] [--amount=<amount>] [--token-id=<tokenId>] [--metadata='<json>']

The same route with useMax preset, so it sends everything.

+
+ + +
+

REST

+

POST/account/{sessionId}/wallet/spend

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  useMax?: boolean
+  dryRun?: boolean
+  broadcast?: boolean
+  save?: boolean
+  walletId: string
+  spendInfo?: {
+    spendTargets: {
+      publicAddress: string | undefined;
+      nativeAmount: string | undefined;
+      uniqueIdentifier: string | undefined;
+      memo: string | undefined;
+      otherParams: {
+        [keys: string]: unknown
+      } | undefined
+    }[];
+    tokenId: string | null;
+    metadata: EdgeMetadata | undefined;
+    networkFeeOption: string | undefined;
+    customNetworkFee: {
+      [keys: string]: unknown
+    } | undefined;
+    rbfTxid: string | undefined;
+    memos: {
+      [keys: string]: unknown
+    }[] | undefined;
+    assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined;
+    savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined;
+    otherParams: {
+      [keys: string]: unknown
+    } | undefined
+  }
+  to?: string
+  nativeAmount?: string
+  amount?: string
+  tokenId?: string | null
+  metadata?: EdgeMetadata
+}
+
Example
{
+  "useMax": true,
+  "dryRun": true,
+  "broadcast": true,
+  "save": true,
+  "walletId": "FS8xJ2kQ…",
+  "spendInfo": {},
+  "to": "string",
+  "nativeAmount": "12345",
+  "amount": "12345",
+  "tokenId": "FS8xJ2kQ…",
+  "metadata": "<EdgeMetadata>"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
useMaxboolean optionalReplace the first target’s amount with the maximum.
dryRunboolean optionalBuild only. Never signs or broadcasts.
broadcastboolean optionalDefaults to true. With false the transaction is signed and not sent, so save then defaults to false as well — recording an unsent transaction marks its inputs spent locally for something the network will never confirm.
saveboolean optionalRecord the transaction in the wallet. Defaults to whatever broadcast is. Setting it true alongside broadcast: false is refused: use --dry-run, or the staged make-spend → sign-tx → broadcast-tx → save-tx flow.
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
spendInfo{ spendTargets: { publicAddress: string | undefined; nativeAmount: string | undefined; uniqueIdentifier: string | undefined; memo: string | undefined; otherParams: { [keys: string]: unknown; } | undefined; }[]; tokenId: string | null; metadata: EdgeMetadata | undefined; networkFeeOption: string | undefined; customNetworkFee: { [keys: string]: unknown; } | undefined; rbfTxid: string | undefined; memos: { [keys: string]: unknown; }[] | undefined; assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined; savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined; otherParams: { [keys: string]: unknown; } | undefined; } optionalA full EdgeSpendInfo, used as-is when present. spendTargets is required.
tostring optionalAddress or BIP21 URI, run through wallet.parseUri.
nativeAmountstring optionalHow much, in native units.
amountstring optionalAlias of nativeAmount.
tokenIdstring | null optionalDefaults to the native asset.
metadataEdgeMetadata optionalWins over anything parsed out of the URI.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"useMax":true,"dryRun":true,"broadcast":true,"save":true,"walletId":"FS8xJ2kQ…","spendInfo":{},"to":"string","nativeAmount":"12345","amount":"12345","tokenId":"FS8xJ2kQ…","metadata":"<EdgeMetadata>"}' \
+  'http://localhost/account/$SESS/wallet/spend'
+
+
+

Response 200

+

{ transaction }, plus saveError when the broadcast succeeded but saving failed. With dryRun, a TransactionHandle instead.

+
unknown
+
Errors

404TOKEN_NOT_FOUND 422INSUFFICIENT_FUNDS 422DUST_SPEND 422PENDING_FUNDS 422SPEND_TO_SELF 400NO_AMOUNT_SPECIFIED 400BAD_REQUEST 503NETWORK_ERROR 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • BIP21 label and message from to become metadata name and notes. An explicit metadata object wins.
  • saveError is the case to handle. Once broadcast, the money is gone, so a failure inside saveTx cannot throw — it would hide the txid of a real payment. The response is 200 with the transaction plus saveError.
  • With dryRun, only makeSpend runs and the response is a transaction handle that expires in 5 minutes.
+
+
+

Build an unsigned transaction.

+
make-spendsrc/cli/engine/routes/spend.ts
+
+

corewallet.makeSpend

Differs from core:

  • to — Shorthand the engine expands into spendTargets, so a one-output send needs no nested JSON.
  • nativeAmount — Amount for the to shorthand, in the smallest unit.
  • amount — Alias of nativeAmount for the to shorthand: the chain’s smallest unit, not whole coins.
+

First step of the staged workflow: nothing is signed and no funds move. Inspect transaction.networkFee on the result before signing.

+
+
+

Command line

+
make-spend --wallet-id=<walletId> [--spend-info='<json>'] [--to=<to>] [--native-amount=<nativeAmount>] [--amount=<amount>] [--token-id=<tokenId>] [--metadata='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/make-spend

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  spendInfo?: {
+    spendTargets: {
+      publicAddress: string | undefined;
+      nativeAmount: string | undefined;
+      uniqueIdentifier: string | undefined;
+      memo: string | undefined;
+      otherParams: {
+        [keys: string]: unknown
+      } | undefined
+    }[];
+    tokenId: string | null;
+    metadata: EdgeMetadata | undefined;
+    networkFeeOption: string | undefined;
+    customNetworkFee: {
+      [keys: string]: unknown
+    } | undefined;
+    rbfTxid: string | undefined;
+    memos: {
+      [keys: string]: unknown
+    }[] | undefined;
+    assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined;
+    savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined;
+    otherParams: {
+      [keys: string]: unknown
+    } | undefined
+  }
+  to?: string
+  nativeAmount?: string
+  amount?: string
+  tokenId?: string | null
+  metadata?: EdgeMetadata
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "spendInfo": {},
+  "to": "string",
+  "nativeAmount": "12345",
+  "amount": "12345",
+  "tokenId": "FS8xJ2kQ…",
+  "metadata": "<EdgeMetadata>"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
spendInfo{ spendTargets: { publicAddress: string | undefined; nativeAmount: string | undefined; uniqueIdentifier: string | undefined; memo: string | undefined; otherParams: { [keys: string]: unknown; } | undefined; }[]; tokenId: string | null; metadata: EdgeMetadata | undefined; networkFeeOption: string | undefined; customNetworkFee: { [keys: string]: unknown; } | undefined; rbfTxid: string | undefined; memos: { [keys: string]: unknown; }[] | undefined; assetAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeAssetAction | undefined; savedAction: import("/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types").EdgeTxAction | undefined; otherParams: { [keys: string]: unknown; } | undefined; } optionalA full EdgeSpendInfo, used as-is when present. spendTargets is required.
tostring optionalAddress or BIP21 URI, run through wallet.parseUri.
nativeAmountstring optionalHow much, in native units.
amountstring optionalAlias of nativeAmount.
tokenIdstring | null optionalDefaults to the native asset.
metadataEdgeMetadata optionalWins over anything parsed out of the URI.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","spendInfo":{},"to":"string","nativeAmount":"12345","amount":"12345","tokenId":"FS8xJ2kQ…","metadata":"<EdgeMetadata>"}' \
+  'http://localhost/account/$SESS/wallet/make-spend'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+  transaction: unknown
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
transactionunknownEdgeTransaction as it stands after this step. Unsigned after make-spend, signed after sign-tx, and carrying a txid once broadcast.
+
+
Errors

404TOKEN_NOT_FOUND 422INSUFFICIENT_FUNDS 422DUST_SPEND 400NO_AMOUNT_SPECIFIED 400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Sign a staged transaction.

+
sign-txsrc/cli/engine/routes/spend.ts
+
+

corewallet.signTx

+

Keeps the same handle and pushes its expiry out another five minutes.

+
+
+

Command line

+
sign-tx <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/sign-tx/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringFrom make-spend.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"objectId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/sign-tx/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+  transaction: unknown
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
transactionunknownEdgeTransaction as it stands after this step. Unsigned after make-spend, signed after sign-tx, and carrying a txid once broadcast.
+
+
Errors

400BAD_REQUEST 404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_WALLET_MISMATCH 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Broadcast a signed transaction.

+
broadcast-txsrc/cli/engine/routes/spend.ts
+
+

corewallet.broadcastTx

+

The irreversible step: once this returns, the funds have left the wallet.

+
+
+

Command line

+
broadcast-tx <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/broadcast-tx/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringFrom sign-tx.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"objectId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/broadcast-tx/$OBJECTID'
+
+
+

Response 200

+

The handle survives, so save-tx can still run.

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+  transaction: unknown
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
transactionunknownEdgeTransaction as it stands after this step. Unsigned after make-spend, signed after sign-tx, and carrying a txid once broadcast.
+
+
Errors

400BAD_REQUEST 503NETWORK_ERROR 404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_WALLET_MISMATCH 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Broadcasting does not record the transaction locally. Follow with save-tx, or it stays missing from history until a sync finds it.
+
+
+

Record a transaction and release its handle.

+
save-txsrc/cli/engine/routes/spend.ts
+
+

corewallet.saveTx

+

Final step. The handle is gone afterwards, so a second call is a 404.

+
+
+

Command line

+
save-tx <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/save-tx/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringThe handle to persist and release.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"objectId":"FS8xJ2kQ…"}' \
+  'http://localhost/account/$SESS/save-tx/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+  objectId: string
+}
+
Example
{
+  "ok": true,
+  "objectId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
okbooleanAlways true; a failure arrives as an error envelope.
objectIdstringThe handle this call consumed. It is now expired.
+
+
Errors

400BAD_REQUEST 404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_WALLET_MISMATCH 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Fee-bump a pending transaction.

+
acceleratesrc/cli/engine/routes/spend.ts
+
+

corewallet.accelerate

Differs from core:

  • transaction — Core names the parameter tx. Spelled out here to match the transaction field every staged-transaction response returns.
+

Replace-by-fee, where the plugin supports it. Returns a new unsigned transaction to sign and broadcast.

+
+
+

Command line

+
accelerate --wallet-id=<walletId> [--object-id=<objectId>] [--transaction='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/accelerate

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  objectId?: string
+  transaction?: {
+    txid: string;
+    currencyCode: string | undefined;
+    nativeAmount: string | undefined;
+    networkFee: string | undefined;
+    walletId: string | undefined
+  }
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "objectId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
objectIdstring optionalHandle of the transaction to bump.
transaction{ txid: string; currencyCode: string | undefined; nativeAmount: string | undefined; networkFee: string | undefined; walletId: string | undefined; } optionalOr the transaction itself.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","objectId":"FS8xJ2kQ…","transaction":{}}' \
+  'http://localhost/account/$SESS/wallet/accelerate'
+
+
+

Response 200

+

Given objectId the same handle is updated; given a transaction a new one is created.

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+  transaction: unknown
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
transactionunknownEdgeTransaction as it stands after this step. Unsigned after make-spend, signed after sign-tx, and carrying a txid once broadcast.
+
+
Errors

400BAD_REQUEST 404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 400OBJECT_KIND_MISMATCH 400OBJECT_WALLET_MISMATCH 400OBJECT_SESSION_MISMATCH 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • A plugin that cannot accelerate returns 400 rather than a null transaction.
+
+
+

Sweep private keys into this wallet.

+
sweep-private-keyssrc/cli/engine/routes/spend.ts
+
+

corewallet.sweepPrivateKeys

Differs from core:

  • spendInfo — Core names this one edgeSpendInfo while makeSpend names the same type spendInfo. Both are spendInfo here.
+

Builds a transaction moving everything from an external key. Returns an unsigned handle: sign, broadcast and save it like any staged spend.

+
+
+

Command line

+
sweep-private-keys --wallet-id=<walletId> --spend-info='<json>'
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/sweep-private-keys

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  spendInfo: {
+    privateKeys: string[];
+    tokenId: string | null;
+    spendTargets: {
+      publicAddress: string | undefined;
+      nativeAmount: string | undefined;
+      uniqueIdentifier: string | undefined;
+      memo: string | undefined;
+      otherParams: {
+        [keys: string]: unknown
+      } | undefined
+    }[];
+    metadata: EdgeMetadata | undefined;
+    memos: {
+      [keys: string]: unknown
+    }[] | undefined
+  }
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "spendInfo": {}
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
spendInfo{ privateKeys: string[]; tokenId: string | null; spendTargets: { publicAddress: string | undefined; nativeAmount: string | undefined; uniqueIdentifier: string | undefined; memo: string | undefined; otherParams: { [keys: string]: unknown; } | undefined; }[]; metadata: EdgeMetadata | undefined; memos: { [keys: string]: unknown; }[] | undefined; }The keys to sweep in privateKeys, plus the optional spendTargets, tokenId, metadata and memos of a spend.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","spendInfo":{}}' \
+  'http://localhost/account/$SESS/wallet/sweep-private-keys'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+  transaction: unknown
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…",
+  "transaction": {}
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
transactionunknownEdgeTransaction as it stands after this step. Unsigned after make-spend, signed after sign-tx, and carrying a txid once broadcast.
+
+
Errors

400BAD_REQUEST 422INSUFFICIENT_FUNDS 503NETWORK_ERROR 404TOKEN_NOT_FOUND 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Sign arbitrary bytes.

+
sign-bytessrc/cli/engine/routes/spend.ts
+
+

corewallet.signBytes

Differs from core:

  • bytes — Core takes a Uint8Array named buf. JSON cannot carry bytes, so this is base64 text.
+

Message signing and proof-of-ownership, for plugins that support it.

+
+
+

Command line

+
sign-bytes --wallet-id=<walletId> [--bytes=<bytes>] [--other-params='<json>']
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/sign-bytes

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  bytes?: string
+  otherParams?: {
+    [keys: string]: unknown
+  }
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "bytes": "string",
+  "otherParams": {}
+}
+ + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
bytesstring optionalBase64. Defaults to empty when absent.
otherParams{ [keys: string]: unknown; } optionalPlugin-specific options. Bitcoin needs { publicAddress }; other plugins take nothing, or refuse the call entirely.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","bytes":"string","otherParams":{}}' \
+  'http://localhost/account/$SESS/wallet/sign-bytes'
+
+
+

Response 200

+
+
Response body
+
{
+  signature: string
+}
+
Example
{
+  "signature": "string"
+}
+ + + + +
signaturestringBase64.
+
+
Errors

400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Invalid base64 is refused with BAD_REQUEST rather than signed.
  • Support is per plugin, and failures surface as 500 INTERNAL_ERROR from the plugin rather than as a typed error: litecoin answers "litecoin doesn't support signBytes", and bitcoin requires otherParams.publicAddress naming which address to sign with.
+
+
+

Fetch a BIP70 payment request.

+
get-payment-protocol-infosrc/cli/engine/routes/spend.ts
+
+

corewallet.getPaymentProtocolInfo

+

Feed spendTargets from the result into make-spend to pay it.

+
+
+

Command line

+
get-payment-protocol-info --wallet-id=<walletId> --payment-protocol-url=<paymentProtocolUrl>
+ + + +
+

REST

+

GET/account/{sessionId}/wallet/get-payment-protocol-info

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+
+
Query
+
{
+  walletId: string
+  paymentProtocolUrl: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "paymentProtocolUrl": "string"
+}
+ + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
paymentProtocolUrlstringThe payment-request URL.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/wallet/get-payment-protocol-info?walletId=…&paymentProtocolUrl=…'
+
+
+

Response 200

+

EdgePaymentProtocolInfo: domain, memo, merchant, nativeAmount, spendTargets.

+
unknown
+
Errors

400BAD_REQUEST 503NETWORK_ERROR 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

URIs

+

Parsing and building BIP21-style payment URIs through the wallet’s own plugin, so chain-specific quirks are handled for you.

+
+
+
+

Parse a payment URI or address.

+
parse-urisrc/cli/engine/routes/uri.ts
+
+

corewallet.parseUri

+

What the GUI address tile does when you paste or scan something.

+
+
+

Command line

+
parse-uri --wallet-id=<walletId> --uri=<uri> [--currency-code=<currencyCode>]
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/parse-uri

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  uri: string
+  currencyCode?: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "uri": "string",
+  "currencyCode": "string"
+}
+ + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
uristringA payment URI or a bare address.
currencyCodestring optionalDisambiguates on chains that carry several assets.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","uri":"string","currencyCode":"string"}' \
+  'http://localhost/account/$SESS/wallet/parse-uri'
+
+
+

Response 200

+

EdgeParsedUri: publicAddress, nativeAmount, currencyCode, metadata, paymentProtocolUrl, …

+
unknown
+
Errors

400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • spend and make-spend run their to field through this same call, so parsing separately is only needed to inspect or confirm first.
+
+
+

Build a payment URI.

+
encode-urisrc/cli/engine/routes/uri.ts
+
+

corewallet.encodeUri

+

For a receive screen or a QR code.

+
+
+

Command line

+
encode-uri --wallet-id=<walletId> --public-address=<publicAddress> [--native-amount=<nativeAmount>] [--label=<label>] [--message=<message>] [--currency-code=<currencyCode>]
+ + + +
+

REST

+

POST/account/{sessionId}/wallet/encode-uri

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  walletId: string
+  publicAddress: string
+  nativeAmount?: string
+  label?: string
+  message?: string
+  currencyCode?: string
+}
+
Example
{
+  "walletId": "FS8xJ2kQ…",
+  "publicAddress": "string",
+  "nativeAmount": "12345",
+  "label": "string",
+  "message": "string",
+  "currencyCode": "string"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
walletIdstringThe wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns 409 AMBIGUOUS_WALLET_ID with details.candidates.
publicAddressstringWhere the payment should go.
nativeAmountstring optionalAmount, in the native unit.
labelstring optionalBIP21 label; becomes metadata.name when parsed back.
messagestring optionalBIP21 message; becomes metadata.notes.
currencyCodestring optionalDisambiguates on chains that carry several assets.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"walletId":"FS8xJ2kQ…","publicAddress":"string","nativeAmount":"12345","label":"string","message":"string","currencyCode":"string"}' \
+  'http://localhost/account/$SESS/wallet/encode-uri'
+
+
+

Response 200

+
+
Response body
+
{
+  uri: string
+}
+
Example
{
+  "uri": "string"
+}
+ + + + +
uristringThe encoded URI, ready for a QR code.
+
+
Errors

400BAD_REQUEST 404WALLET_NOT_FOUND 409AMBIGUOUS_WALLET_ID 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Only these five fields are read; a fuller EdgeEncodeUri has its extras ignored.
+

Local settings

+

Device-local account settings, stored outside the synced repos. They follow the account but never leave the machine.

+
+

Local settings

+

Device-local account settings, stored outside the synced repos.

+
+
+
+

Local settings.

+
local-settingssrc/cli/engine/routes/localSettings.ts
+
+

coreGUI code (src/util/localAccountSettings), reached through account.localDisklet.

+

Device-local account settings, stored in Settings.json on account.localDisklet. They are not synced — a phone and a CLI keep separate copies unless they share an Edge data directory.

+
+
+

Command line

+
local-settings
+ + + +
+

REST

+

GET/account/{sessionId}/local-settings

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/local-settings'
+
+
+

Response 200

+
+
Response body
+
{
+  spamFilterOn: boolean
+}
+
Example
{
+  "spamFilterOn": true
+}
+ + + + +
spamFilterOnbooleanHide spam transactions in get-transactions results. Defaults to true, matching the GUI. The filter hides rows; it never changes stored metadata.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+ +
+
+

Change local settings.

+
local-settingssrc/cli/engine/routes/localSettings.ts
+
+

coreGUI code (src/util/localAccountSettings).

+

Writes device-local account settings. Every option is a field on the body; spamFilterOn is the only one today, and new options are added alongside it.

+
+
+

Command line

+
local-settings --spam-filter-on=true|false
+ + +

With no flag the command reads; with one it writes.

+
+
+

REST

+

POST/account/{sessionId}/change-local-settings

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
+ + +
+
Request body
+
{
+  spamFilterOn: boolean
+}
+
Example
{
+  "spamFilterOn": true
+}
+ + + + +
spamFilterOnbooleanHide spam transactions in get-transactions results. Defaults to true, matching the GUI. The filter hides rows; it never changes stored metadata.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"spamFilterOn":true}' \
+  'http://localhost/account/$SESS/change-local-settings'
+
+
+

Response 200

+
+
Response body
+
{
+  spamFilterOn: boolean
+}
+
Example
{
+  "spamFilterOn": true
+}
+ + + + +
spamFilterOnbooleanHide spam transactions in get-transactions results. Defaults to true, matching the GUI. The filter hides rows; it never changes stored metadata.
+
+
Errors

401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Omitting a field is a 400, not a no-op, so a caller cannot clear a setting by accident.
+

Exchange rates

+

Fiat and crypto pricing, current and historical.

+
+

Exchange rates

+

Historical and current rates through the same batching queue the GUI uses. No session required.

+
+
+
+

Batch crypto and fiat rate lookups.

+
rates-querysrc/cli/engine/routes/rates.ts
+
+

coreGUI code (src/util/exchangeRates): getHistoricalCryptoRate and getHistoricalFiatRate.

+

Concurrent lookups share one rates-server queue, so asking for many rates at once costs a single upstream request.

+
+
+

Command line

+
rates-query [--crypto='<json>'] [--fiat='<json>']
+ + + +
+

REST

+

POST/rates/query

+ + + +
+
Request body
+
{
+  crypto?: {
+    pluginId: string;
+    tokenId: string | null;
+    targetFiat: string | undefined;
+    date: string | undefined
+  }[]
+  fiat?: {
+    fiatCode: string;
+    targetFiat: string | undefined;
+    date: string | undefined
+  }[]
+}
+
Example
{
+  "crypto": [
+    {}
+  ],
+  "fiat": [
+    {}
+  ]
+}
+ + + + + + + + + +
crypto{ pluginId: string; tokenId: string | null; targetFiat: string | undefined; date: string | undefined; }[] optionalCrypto rates to fetch.
fiat{ fiatCode: string; targetFiat: string | undefined; date: string | undefined; }[] optionalFiat rates to fetch.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"crypto":[{}],"fiat":[{}]}' \
+  'http://localhost/rates/query'
+
+
+

Response 200

+
+
Response body
+
{
+  crypto: {
+    pluginId: string;
+    tokenId: string | null;
+    targetFiat: string;
+    date: string;
+    rate: number
+  }[]
+  fiat: {
+    fiatCode: string;
+    targetFiat: string;
+    date: string;
+    rate: number
+  }[]
+}
+
Example
{
+  "crypto": [
+    {}
+  ],
+  "fiat": [
+    {}
+  ]
+}
+ + + + + + + + + +
crypto{ pluginId: string; tokenId: string | null; targetFiat: string; date: string; rate: number; }[]Always present; empty when no crypto rates were requested.
fiat{ fiatCode: string; targetFiat: string; date: string; rate: number; }[]Always present; empty when no fiat rates were requested.
+
+
Errors

400BAD_REQUEST 503NETWORK_ERROR

+
+

Notes

  • A rate the server cannot supply comes back as 0 rather than an error, so check for zero before dividing.
+
+
+

Convert a USD amount into native units.

+
rates-usd-to-nativesrc/cli/engine/routes/rates.ts
+
+

coreGUI code (src/util/exchangeRates): getHistoricalCryptoRate.

+

Turns a fiat notional into the native amount a spend needs.

+
+
+

Command line

+
rates-usd-to-native --usd-amount=<usdAmount> --plugin-id=<pluginId> [--token-id=<tokenId>] --multiplier=<multiplier> [--date=<date>]
+ + + +
+

REST

+

POST/rates/usd-to-native

+ + + +
+
Request body
+
{
+  usdAmount: string
+  pluginId: string
+  tokenId?: string | null
+  multiplier: string
+  date?: string
+}
+
Example
{
+  "usdAmount": "12345",
+  "pluginId": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "multiplier": "string",
+  "date": "2026-09-02T16:35:00.000Z"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + +
usdAmountstringA positive decimal string.
pluginIdstringWhich chain to price.
tokenIdstring | null optionalDefaults to the native asset.
multiplierstringNative units per whole coin, as a positive decimal string. This route is not session-scoped, so the engine cannot read the asset’s denomination from core and will not guess one.
datestring optionalISO-8601. Omitted, the current time is sent to the rates server.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"usdAmount":"12345","pluginId":"FS8xJ2kQ…","tokenId":"FS8xJ2kQ…","multiplier":"string","date":"2026-09-02T16:35:00.000Z"}' \
+  'http://localhost/rates/usd-to-native'
+
+
+

Response 200

+
+
Response body
+
{
+  usdAmount: number
+  pluginId: string
+  tokenId: string | null
+  multiplier: string
+  date: string
+  rate: number
+  displayAmount: string
+  nativeAmount: string
+}
+
Example
{
+  "usdAmount": 0,
+  "pluginId": "FS8xJ2kQ…",
+  "tokenId": "FS8xJ2kQ…",
+  "multiplier": "string",
+  "date": "2026-09-02T16:35:00.000Z",
+  "rate": 1,
+  "displayAmount": "12345",
+  "nativeAmount": "12345"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
usdAmountnumberEchoed as a number, though it is sent as a string.
pluginIdstringCurrency plugin the amount was converted for.
tokenIdstring | nullThe asset, or null for the chain’s own coin.
multiplierstringNative units per whole coin, which is what displayAmount was multiplied by to reach nativeAmount.
datestringThe timestamp actually used for the rate.
ratenumberUSD per whole coin at that date.
displayAmountstringWhole coins, to 8 decimal places.
nativeAmountstringWhat a spend actually takes.
+
+
Errors

400BAD_REQUEST 404NOT_FOUND 503NETWORK_ERROR

+
+

Notes

  • displayAmount is rounded to 8 decimals before conversion, so assets with finer precision lose the tail. For an exact figure use rates-query and do the arithmetic yourself.
  • multiplier is required. The engine has no logged-in account here, so it cannot read the asset's denomination from core, and a guessed multiplier would return a nativeAmount wrong by orders of magnitude under a field documented as what a spend takes.
+

Object handles

+

A core value with methods on it cannot cross JSON, so the engine keeps it and hands back an id. These read and release any of them.

+
+

Object handles

+

A core value with methods on it — a staged transaction, a swap quote, a pending login — cannot cross JSON, so the engine keeps it and hands back an id. These read and release any of them.

+
+
+
+

Inspect an object handle.

+
object-getsrc/cli/engine/routes/objects.ts
+
+

coreEngine handle store; core identifies these values by object reference.

+

Works for every kind: transactions, pending logins, swap quotes and lobbies.

+
+
+

Command line

+
object-get <objectId>
+ + + +
+

REST

+

GET/account/{sessionId}/object/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/account/$SESS/object/$OBJECTID'
+
+
+

Response 200

+

The handle fields, plus a value holding a JSON-safe view of the object. A staged transaction is returned whole; pending logins and swap quotes are summarised, because the values behind them are live core objects; a lobby has no scalar projection and reports value: null.

+
+
Response body
+
{
+  objectId: string
+  kind: string
+  createdAt: string
+  expiresAt: string
+  sessionId?: string
+  walletId?: string
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "kind": "string",
+  "createdAt": "2026-09-02T16:35:00.000Z",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "sessionId": "FS8xJ2kQ…",
+  "walletId": "FS8xJ2kQ…"
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
objectIdstringHandle for the value the engine is holding. Pass it to the calls that consume it.
kindstringWhat the handle refers to, which decides the calls that accept it.
createdAtstringWhen the engine took the handle.
expiresAtstringWhen the engine drops the handle. Handles live 5 minutes.
sessionIdstring optionalSession that created the handle; only that session may use it.
walletIdstring optionalWallet the handle is bound to, when it belongs to one.
+
+
Errors

404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 409OBJECT_IN_USE 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+

Notes

  • Reading does not extend the TTL. Only a step that updates the value does.
+
+
+

Release an object handle.

+
object-deletesrc/cli/engine/routes/objects.ts
+
+

coreEngine handle store.

+

Runs the handle's cleanup — closing a swap quote, cancelling a pending login — instead of waiting out the TTL.

+
+
+

Command line

+
object-delete <objectId>
+ + + +
+

REST

+

POST/account/{sessionId}/object/delete/{objectId}

+
Path
sessionIdstringFrom a successful login. The CLI supplies this from session.json, --session, or EDGE_CLI_SESSION.
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/account/$SESS/object/delete/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+  objectId: string
+}
+
Example
{
+  "ok": true,
+  "objectId": "FS8xJ2kQ…"
+}
+ + + + + + + + + +
okbooleanAlways true; a failure arrives as an error envelope.
objectIdstringThe handle this call consumed. It is now expired.
+
+
Errors

404OBJECT_NOT_FOUND 410OBJECT_EXPIRED 409OBJECT_IN_USE 400OBJECT_SESSION_MISMATCH 401INVALID_SESSION 401SESSION_EXPIRED

+
+ +

Admin

+

The $internalStuff escape hatch: login-server and sync-repo access that no ordinary caller needs.

+
+

Admin

+

Debugging only — not for production apps. These reach into context.$internalStuff, the private surface of edge-core-js, and can corrupt an account’s synced repos. They take no sessionId: they act on the context, not on a logged-in account.

+
+
+
+

Raw login-server request.

+
admin-auth-requestsrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.authRequest

+

Sends an arbitrary request with the context's credentials attached. Debugging only — this is core's private surface.

+
+
+

Command line

+
admin-auth-request --method=<method> --path=<path> [--body='<json>']
+ + + +
+

REST

+

POST/admin/auth-request

+ + + +
+
Request body
+
{
+  method: string
+  path: string
+  body?: {
+    [keys: string]: unknown
+  }
+}
+
Example
{
+  "method": "string",
+  "path": "string",
+  "body": {}
+}
+ + + + + + + + + + + + + + +
methodstringHTTP method, e.g. GET.
pathstringLogin-server path, not an engine path.
body{ [keys: string]: unknown; } optionalRequest body, when the method takes one.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"method":"string","path":"string","body":{}}' \
+  'http://localhost/admin/auth-request'
+
+
+

Response 200

+

Whatever the login server returned.

+
unknown
+
Errors

400BAD_REQUEST 503NETWORK_ERROR

+
+ +
+
+

Hash a username.

+
admin-hash-usernamesrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.hashUsername

+

Reproduces the login server's hashing, to derive a login id offline.

+
+
+

Command line

+
admin-hash-username --username=<username>
+ + + +
+

REST

+

GET/admin/hash-username

+ +
+
Query
+
{
+  username: string
+}
+
Example
{
+  "username": "string"
+}
+ + + + +
usernamestringThe name to hash.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/admin/hash-username?username=…'
+
+
+

Response 200

+
+
Response body
+
{
+  loginId: string
+}
+
Example
{
+  "loginId": "FS8xJ2kQ…"
+}
+ + + + +
loginIdstringBase58.
+
+ +
+ +
+
+

Create a lobby.

+
admin-make-lobbysrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.makeLobby

+

A lobby polls the login server until closed, so the engine parks it under a lobby_ handle and closes it on expiry rather than leaking the poll.

+
+
+

Command line

+
admin-make-lobby [--lobby-request='<json>'] [--period-seconds=<period>]
+ + + +
+

REST

+

POST/admin/make-lobby

+ + + +
+
Request body
+
{
+  lobbyRequest?: {
+    [keys: string]: unknown
+  }
+  period?: number
+}
+
Example
{
+  "lobbyRequest": {},
+  "period": 0
+}
+ + + + + + + + + +
lobbyRequest{ [keys: string]: unknown; } optionalDefaults to {}.
periodnumber optionalPoll interval in seconds. At least 0.25; the engine converts to the milliseconds core takes.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"lobbyRequest":{},"period":0}' \
+  'http://localhost/admin/make-lobby'
+
+
+

Response 200

+
+
Response body
+
{
+  objectId: string
+  expiresAt: string
+  lobbyId: string
+  replies: unknown[]
+}
+
Example
{
+  "objectId": "FS8xJ2kQ…",
+  "expiresAt": "2026-09-02T16:35:00.000Z",
+  "lobbyId": "FS8xJ2kQ…",
+  "replies": [
+    {}
+  ]
+}
+ + + + + + + + + + + + + + + + + + + +
objectIdstringThe parked handle.
expiresAtstringWhen the engine closes the lobby and stops polling.
lobbyIdstringIdentifies the lobby to the party joining it.
repliesunknown[]Empty at creation; re-read to see replies.
+
+
Errors

503NETWORK_ERROR

+
+

Notes

  • Release it with admin-lobby-handle-delete, or the poll runs for the full five minutes.
+
+
+

Close a parked lobby.

+
admin-lobby-handle-deletesrc/cli/engine/routes/admin.ts
+
+

coreEngine handle store for a lobby created via makeLobby.

+ +
+

Command line

+
admin-lobby-handle-delete <objectId>
+ + + +
+

REST

+

POST/admin/lobby-handle/delete/{objectId}

+
Path
objectIdstringAn ephemeral object handle id.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  'http://localhost/admin/lobby-handle/delete/$OBJECTID'
+
+
+

Response 200

+
+
Response body
+
{
+  ok: boolean
+}
+
Example
{
+  "ok": true
+}
+ + + + +
okbooleanAlways true; a failure arrives as an error envelope.
+
+
Errors

404OBJECT_NOT_FOUND 400OBJECT_KIND_MISMATCH 409OBJECT_IN_USE

+
+

Notes

  • Not under /account/{sessionId}/objects/, because admin lobbies belong to no session.
+
+
+

Read a lobby's contents.

+
admin-fetch-lobby-requestsrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.fetchLobbyRequest

+ +
+

Command line

+
admin-fetch-lobby-request <lobbyId>
+ + + +
+

REST

+

GET/admin/fetch-lobby-request/{lobbyId}

+
Path
lobbyIdstringWhich lobby to read.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/admin/fetch-lobby-request/$LOBBYID?lobbyId=…'
+
+
+

Response 200

+

The raw lobby request.

+
unknown
+
Errors

503NETWORK_ERROR

+
+ +
+
+

Reply to a lobby.

+
admin-send-lobby-replysrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.sendLobbyReply

+ +
+

Command line

+
admin-send-lobby-reply <lobbyId> --lobby-request='<json>' [--reply-data='<json>']
+ + + +
+

REST

+

POST/admin/send-lobby-reply/{lobbyId}

+
Path
lobbyIdstringWhich lobby to answer.
+ + +
+
Request body
+
{
+  lobbyRequest: {
+    [keys: string]: unknown
+  }
+  replyData?: unknown
+}
+
Example
{
+  "lobbyRequest": {},
+  "replyData": {}
+}
+ + + + + + + + + +
lobbyRequest{ [keys: string]: unknown; }Normally the object from admin-fetch-lobby-request.
replyDataunknown optionalPayload for the requester.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"lobbyId":"FS8xJ2kQ…","lobbyRequest":{},"replyData":{}}' \
+  'http://localhost/admin/send-lobby-reply/$LOBBYID'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST 503NETWORK_ERROR

+
+ +
+
+

Sync a repo.

+
admin-sync-reposrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.syncRepo

+ +
+

Command line

+
admin-sync-repo <syncKey>
+ + + +
+

REST

+

POST/admin/sync-repo/{syncKey}

+
Path
syncKeystringBase58 repo sync key.
+ + + +
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"syncKey":"string"}' \
+  'http://localhost/admin/sync-repo/$SYNCKEY'
+
+
+

Response 200

+

The changeset summary.

+
unknown
+
Errors

400BAD_REQUEST 503NETWORK_ERROR

+
+ +
+
+

List repo contents.

+
admin-repo-listsrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.getRepoDisklet

+ +
+

Command line

+
admin-repo-list <syncKey> [--path=<path>] --data-key=<dataKey>
+ + + +
+

REST

+

GET/admin/repo-list/{syncKey}

+
Path
syncKeystringBase58 repo sync key.
+
+
Query
+
{
+  path?: string
+  dataKey: string
+}
+
Example
{
+  "path": "string",
+  "dataKey": "string"
+}
+ + + + + + + + + +
pathstring optionalSubdirectory. Defaults to the repo root.
dataKeystringBase58 repo data key.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/admin/repo-list/$SYNCKEY?syncKey=…&dataKey=…'
+
+
+

Response 200

+
+
Response body
+
{
+  listing: unknown
+}
+
Example
{
+  "listing": {}
+}
+ + + + +
listingunknownPath to entry type: file or folder.
+
+
Errors

400BAD_REQUEST

+
+ +
+
+

Read a repo file.

+
admin-repo-getsrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.getRepoDisklet

+ +
+

Command line

+
admin-repo-get <syncKey> --path=<path> --data-key=<dataKey>
+ + + +
+

REST

+

GET/admin/repo-get/{syncKey}

+
Path
syncKeystringBase58 repo sync key.
+
+
Query
+
{
+  path: string
+  dataKey: string
+}
+
Example
{
+  "path": "string",
+  "dataKey": "string"
+}
+ + + + + + + + + +
pathstringPath within the repo.
dataKeystringBase58 repo data key.
+
+ + +
Example
+
curl --unix-socket "$SOCK" \
+  'http://localhost/admin/repo-get/$SYNCKEY?path=…&syncKey=…&dataKey=…'
+
+
+

Response 200

+
+
Response body
+
{
+  text: string
+}
+
Example
{
+  "text": "string"
+}
+ + + + +
textstringThe file contents.
+
+
Errors

404NOT_FOUND 400BAD_REQUEST

+
+ +
+
+

Write a repo file.

+
admin-repo-setsrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.getRepoDisklet

+

Writes directly into a synced repo, bypassing every core-level invariant. A malformed write can break the account for real clients.

+
+
+

Command line

+
admin-repo-set <syncKey> --path=<path> --text=<text> --data-key=<dataKey>
+ + + +
+

REST

+

POST/admin/repo-set/{syncKey}

+
Path
syncKeystringBase58 repo sync key.
+ + +
+
Request body
+
{
+  path: string
+  text: string
+  dataKey: string
+}
+
Example
{
+  "path": "string",
+  "text": "string",
+  "dataKey": "string"
+}
+ + + + + + + + + + + + + + +
pathstringPath within the repo.
textstringThe contents to write.
dataKeystringBase58 repo data key.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"path":"string","text":"string","syncKey":"string","dataKey":"string"}' \
+  'http://localhost/admin/repo-set/$SYNCKEY'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST

+
+ +
+
+

Delete a repo file.

+
admin-repo-deletesrc/cli/engine/routes/admin.ts
+
+

corecontext.$internalStuff.getRepoDisklet

+

Destructive, and not undoable from this API.

+
+
+

Command line

+
admin-repo-delete <syncKey> --path=<path> --data-key=<dataKey>
+ + + +
+

REST

+

POST/admin/repo-delete/{syncKey}

+
Path
syncKeystringBase58 repo sync key.
+ + +
+
Request body
+
{
+  path: string
+  dataKey: string
+}
+
Example
{
+  "path": "string",
+  "dataKey": "string"
+}
+ + + + + + + + + +
pathstringPath within the repo.
dataKeystringBase58 repo data key.
+
+
Example
+
curl --unix-socket "$SOCK" \
+  -X POST \
+  -H 'Content-Type: application/json' \
+  -d '{"path":"string","syncKey":"string","dataKey":"string"}' \
+  'http://localhost/admin/repo-delete/$SYNCKEY'
+
+
+

Response 204

+

No body.

+
Errors

400BAD_REQUEST

+
+ +
+

Error codes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BAD_REQUEST400engineMalformed JSON, or a missing / wrongly typed field.
MISSING_BITWAVE_ACCOUNT_ID400engineBitwave export requested with no account id in the query and none saved in the wallet’s exportTxInfo.json.
OBJECT_KIND_MISMATCH400engineThe handle exists but is a different kind (e.g. a swap quote passed to sign-tx).
OBJECT_SESSION_MISMATCH400engineThe handle belongs to a different session.
OBJECT_WALLET_MISMATCH400engineThe transaction handle belongs to a different wallet.
INVALID_SESSION401engineUnknown sessionId.
SESSION_EXPIRED401engineAuto-logged-out after the account’s idle timeout. An explicitly logged-out session is gone rather than expired, and answers INVALID_SESSION.
NOT_FOUND404engineNo route matched, or a generic missing resource.
NO_LOGIN_REQUEST404engineThe lobby exists but carries no pending login request.
OBJECT_NOT_FOUND404engineNo handle with that objectId.
PENDING_LOGIN_NOT_FOUND404engineNo pending Edge login with that pendingId.
TOKEN_NOT_FOUND404engineUnknown token id for this wallet.
UNAUTHORIZED401engineThe TCP transport requires the bearer token from the engine run file in an X-Edge-Token header. The unix socket needs none: its 0600 mode is the check.
FORBIDDEN403engineA TCP request carried an Origin header, or a Host that is not the address the listener is bound to. Both are refused so a web page cannot reach the engine, directly or by rebinding a name onto its port.
USER_NOT_FOUND404engineNo local user matches that username or login id.
WALLET_NOT_FOUND404engineNo wallet matches that id or prefix.
ENGINE_UNAVAILABLE503clientThe client could not reach or start an engine. Written by the client, so it never arrives over the wire; exit code 7.
USAGE400clientBad argv: an unknown command or flag, a missing flag value, or an unparseable structured flag. Written by the client; exit code 2.
METHOD_NOT_ALLOWED405engineThe path exists but not for this HTTP method.
AMBIGUOUS_WALLET_ID409engineA wallet id prefix matched more than one wallet. details: details.candidates
OBJECT_EXPIRED410engineThe handle was read after its TTL but before the 15-second sweeper released it. Once swept it is gone, so a handle past its TTL usually answers OBJECT_NOT_FOUND instead.
OBJECT_IN_USE409engineA consuming call on this handle is already in flight. Fund-moving calls outlive the client socket timeout, so a retry is refused rather than sending twice.
PAYLOAD_TOO_LARGE413engineRequest body over 4 MiB.
UNSUPPORTED_MEDIA_TYPE415engineBody present but not application/json.
INTERNAL_ERROR500engineUnmapped engine or plugin failure.
ENGINE_SHUTTING_DOWN503engineIdle or explicit shutdown already in progress.
USERNAME_ERROR400coreUnknown username, or an invalid recovery key.
NO_AMOUNT_SPECIFIED400coreZero-amount spend.
SAME_CURRENCY400coreSwap between identical currencies.
PASSWORD_ERROR401coreWrong password, PIN, or recovery answers. details: details.wait (seconds) when rate-limited
OTP_REQUIRED401coreMissing or wrong 2FA token. details: reason (ip|otp), loginId, resetToken, resetDate, voucherId, voucherAuth, voucherActivates
CHALLENGE_REQUIRED403coreThe login server wants a CAPTCHA. Retry with challengeId. details: challengeId, challengeUri
PIN_DISABLED403corePIN login is not enabled on this device.
SWAP_PERMISSION403coreThe swap plugin refused the request. details: pluginId, reason: geoRestriction | noVerification | needsActivation
INSUFFICIENT_FUNDS422coreNot enough balance to cover amount plus fee. details: tokenId, networkFee
DUST_SPEND422coreAmount below the network dust threshold.
PENDING_FUNDS422coreBalance exists but is unconfirmed.
SPEND_TO_SELF422coreDestination address belongs to the source wallet.
SWAP_ABOVE_LIMIT422coreAmount exceeds the plugin maximum. details: swapPluginId, nativeMax, direction
SWAP_BELOW_LIMIT422coreAmount below the plugin minimum. details: swapPluginId, nativeMin, direction. nativeMin is an empty string when the plugin refused the amount without reporting a limit — core defaults it, and the engine passes it through rather than inventing one.
SWAP_CURRENCY422coreThe plugin does not support that pair. details: pluginId, fromTokenId, toTokenId
SWAP_ADDRESS422coreAddress unusable for this swap. details: swapPluginId, reason: mustMatch | mustBeActivated
OBSOLETE_API426coreThe login server rejected this client version.
NETWORK_ERROR503coreCould not reach an Edge server.
+

CLI exit codes

+
0OKSuccess.
1GENERICAny failure with no more specific mapping.
2USAGEBad argv: unknown flag, missing value, extra positional.
3AUTHINVALID_SESSION, SESSION_EXPIRED, UNAUTHORIZED, FORBIDDEN, PASSWORD_ERROR, OTP_REQUIRED, CHALLENGE_REQUIRED, PIN_DISABLED.
4NOT_FOUNDNOT_FOUND, WALLET_NOT_FOUND, TOKEN_NOT_FOUND.
5VALIDATIONBAD_REQUEST, INSUFFICIENT_FUNDS, DUST_SPEND, PENDING_FUNDS, SPEND_TO_SELF, NO_AMOUNT_SPECIFIED, AMBIGUOUS_WALLET_ID, USERNAME_ERROR.
6NETWORKNETWORK_ERROR, or any response with HTTP status 503.
7ENGINEENGINE_SHUTTING_DOWN, or the client could not connect to or spawn the engine.
+
+
+ + \ No newline at end of file diff --git a/docs/api/dist/openapi.json b/docs/api/dist/openapi.json new file mode 100644 index 00000000000..8ceaceeb8e9 --- /dev/null +++ b/docs/api/dist/openapi.json @@ -0,0 +1,10227 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Edge CLI", + "version": "1.0.0", + "description": "The `edge-cli` command line and the `edge-engine` JSON REST API, generated from the route declarations in `src/cli/engine/routes/`." + }, + "servers": [ + { + "url": "http://localhost", + "description": "Unix socket at ~/.edge-cli/run//engine.sock" + }, + { + "url": "http://127.0.0.1:9008", + "description": "Loopback TCP, when started with --tcp=9008. Requires the X-Edge-Token header; see securitySchemes." + } + ], + "security": [ + {}, + { + "edgeTcpToken": [] + } + ], + "tags": [ + { + "name": "Lifecycle", + "description": "Lifecycle and configuration of the `edge-engine` daemon. None of these have an `edge-core-js` equivalent — they describe the daemon itself — and none need a session." + }, + { + "name": "Device and usernames", + "description": "Calls on the shared `EdgeContext`: local device state and login-server queries that do not need a session." + }, + { + "name": "Login methods", + "description": "Every successful login returns a [Session](#schema-Session) and registers it in the engine, so later calls need only the `sessionId`. The CLI writes that id to `session.json` automatically." + }, + { + "name": "Session", + "description": "Calls on a logged-in `EdgeAccount`, addressed by `sessionId`. All of these can also return `401 INVALID_SESSION` or `401 SESSION_EXPIRED`." + }, + { + "name": "Local settings", + "description": "Device-local account settings, stored outside the synced repos." + }, + { + "name": "Credentials", + "description": "Password, PIN, username and recovery changes on a logged-in account." + }, + { + "name": "Two-factor authentication", + "description": "OTP state and the reset flow a user falls back on after losing their authenticator." + }, + { + "name": "Vouchers", + "description": "When 2FA blocks a login, the login server issues a voucher an already-trusted device can approve or reject." + }, + { + "name": "Approving a login", + "description": "The other side of `request-edge-login`: a logged-in account inspecting and approving a login somebody scanned." + }, + { + "name": "Keys", + "description": "Raw key infrastructure beneath the wallet API. Several of these return private key material, and the engine has no transport auth — treat any process that can reach the socket as fully trusted." + }, + { + "name": "Wallet state", + "description": "Account-level wallet listing and creation, then per-wallet calls. A `{walletId}` segment accepts a unique prefix, so those routes can also return `404 WALLET_NOT_FOUND` or `409 AMBIGUOUS_WALLET_ID`." + }, + { + "name": "Tokens", + "description": "Which tokens a wallet tracks. Enabled tokens are the ones it syncs balances for; detected ones were seen on-chain but are not yet enabled." + }, + { + "name": "Transactions", + "description": "Reading transaction history, exporting it, and editing its metadata." + }, + { + "name": "Object handles", + "description": "A core value with methods on it — a staged transaction, a swap quote, a pending login — cannot cross JSON, so the engine keeps it and hands back an id. These read and release any of them." + }, + { + "name": "Spending", + "description": "Two ways to send funds. `spend` does the whole thing in one call; the staged workflow — `make-spend`, `sign-tx`, `broadcast-tx`, `save-tx` — hands back an object handle at each step so fees can be inspected before committing." + }, + { + "name": "Swap quotes", + "description": "Cross-asset exchange. Quotes are live objects held server-side under a `swap_` handle, so approving one means naming its `objectId` rather than re-uploading the quote." + }, + { + "name": "URIs", + "description": "Parsing and building BIP21-style payment URIs through the wallet’s own plugin, so chain-specific quirks are handled for you." + }, + { + "name": "Exchange rates", + "description": "Historical and current rates through the same batching queue the GUI uses. No session required." + }, + { + "name": "Data store", + "description": "The account’s synced key-value store, where plugins keep their own state. One route per `EdgeDataStore` method." + }, + { + "name": "Admin", + "description": "**Debugging only — not for production apps.** These reach into `context.$internalStuff`, the private surface of `edge-core-js`, and can corrupt an account’s synced repos. They take no `sessionId`: they act on the context, not on a logged-in account." + }, + { + "name": "Event stream", + "description": "A Server-Sent Events feed of engine activity, served outside the router because the response never ends." + } + ], + "paths": { + "/engine/status": { + "get": { + "operationId": "engineStatus", + "summary": "Engine liveness and summary.", + "description": "**Core call:** _none — Engine lifecycle; the daemon is not part of the core API._\n\n**Command line**\n\n```\nengine-status\n```\n\nThe readiness probe the client polls after auto-spawning the engine.", + "tags": [ + "Lifecycle" + ], + "x-cli": { + "command": "engine-status", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine lifecycle; the daemon is not part of the core API.", + "x-source": "src/cli/engine/routes/status.ts", + "parameters": [], + "responses": { + "200": { + "description": "`idleShutdownAt` is null while a session or a subscription holds the engine open, and `tcpPort` is null unless started with `--tcp`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pid": { + "type": "number", + "description": "The daemon process, for `kill` when it will not stop." + }, + "apiVersion": { + "type": "string", + "description": "The API this engine speaks. A client refusing to talk to an older engine checks this." + }, + "uptimeSeconds": { + "type": "number", + "description": "How long the daemon has been running." + }, + "sessionCount": { + "type": "number", + "description": "Logged-in accounts held open right now." + }, + "testMode": { + "type": "boolean", + "description": "True when the engine is not pointed at production: the tester fleet, or the in-process fake world under `--fake`." + }, + "idleShutdownAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the engine will exit for want of work. Null while anything is holding it open — a logged-in session, a live subscription, a request being served, or a pending edge login, whose handle belongs to no session — and null when the timeout is disabled." + }, + "tcpPort": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "The loopback port, null unless started with `--tcp`." + }, + "socketPath": { + "type": "string", + "description": "Unix socket the CLI connects to." + }, + "rateCachedCount": { + "type": "number", + "description": "Exchange rates held in the engine’s process cache. It is bounded and cleared when the last session goes away, and this is how an operator sees it." + }, + "locale": { + "type": "string", + "description": "Language tag the engine resolved at boot." + }, + "localeMatched": { + "type": "boolean", + "description": "Whether a translation table for that tag was actually found. False means the tag was accepted but the engine is answering in English, which is otherwise indistinguishable from a build that has the language." + }, + "decimalSeparator": { + "type": "string", + "description": "Decimal mark for that locale." + }, + "groupingSeparator": { + "type": "string", + "description": "Thousands mark for that locale." + } + }, + "required": [ + "pid", + "apiVersion", + "uptimeSeconds", + "sessionCount", + "testMode", + "idleShutdownAt", + "tcpPort", + "socketPath", + "rateCachedCount", + "locale", + "localeMatched", + "decimalSeparator", + "groupingSeparator" + ] + } + } + } + }, + "default": { + "description": "ENGINE_SHUTTING_DOWN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/engine/config": { + "get": { + "operationId": "engineConfig", + "summary": "Configured context options.", + "description": "**Core call:** _none — Reflects the EdgeContextOptions the engine supplied at startup._\n\n**Command line**\n\n```\nengine-config\n```\n\nWhat the engine passed to `makeEdgeContext`. Contains no secrets. Use it to assert tester hosts before a test run.", + "tags": [ + "Lifecycle" + ], + "x-cli": { + "command": "engine-config", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Reflects the EdgeContextOptions the engine supplied at startup.", + "x-source": "src/cli/engine/routes/status.ts", + "parameters": [], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appId": { + "type": "string", + "description": "Application ID the engine was started with." + }, + "testMode": { + "type": "boolean", + "description": "True when the engine is not pointed at production: the tester fleet, or the in-process fake world under `--fake`. Read `servers` to tell those apart." + }, + "directory": { + "type": "string", + "description": "Working directory holding the core data." + }, + "servers": { + "anyOf": [ + { + "description": "{ [keys: string]: string" + }, + { + "description": "string[]; }" + } + ], + "description": "The URLs this engine talks to, keyed by role. `syncServer` is a list, since core rotates across the sync fleet." + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Plugin IDs the engine loaded, sorted." + } + }, + "required": [ + "appId", + "testMode", + "directory", + "servers", + "plugins" + ] + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/engine/stop": { + "post": { + "operationId": "engineStop", + "summary": "Stop the engine.", + "description": "**Core call:** _none — Engine lifecycle. Internally calls `context.close()`._\n\n**Command line**\n\n```\nengine-stop\n```\n\nLogs out every session, closes the context, unlinks the socket and run-file, then exits. The engine answers before it starts tearing down, so a response is not proof the process is gone.", + "tags": [ + "Lifecycle" + ], + "x-cli": { + "command": "engine-stop", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine lifecycle. Internally calls `context.close()`.", + "x-source": "src/cli/engine/routes/status.ts", + "parameters": [], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "Always true; a failure arrives as an error envelope." + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/local-users": { + "get": { + "operationId": "localUsers", + "summary": "List local users on this device.", + "description": "**Core call:** `context.localUsers`\n\n**Command line**\n\n```\nlocal-users\n```\n\n", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "local-users", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.localUsers", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "200": { + "description": "Everything `context.localUsers` reports, including which login methods each user has enabled on this device.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "localUsers": { + "type": "array", + "items": {}, + "description": "`EdgeUserInfo[]`: one entry per account cached on this device." + } + }, + "required": [ + "localUsers" + ] + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/forget-account": { + "post": { + "operationId": "forgetAccount", + "summary": "Forget an account on this device.", + "description": "**Core call:** `context.forgetAccount`\n\n**Command line**\n\n```\nforget-account --root-login-id=\n```\n\nRemoves locally cached credentials. The remote account is untouched.", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "forget-account", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.forgetAccount", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "USER_NOT_FOUND, BAD_REQUEST", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rootLoginId": { + "type": "string", + "description": "Core takes a `rootLoginId`. A username is also accepted and resolved against `localUsers` first, so callers need not hash it." + } + }, + "required": [ + "rootLoginId" + ] + } + } + } + } + } + }, + "/username-available": { + "get": { + "operationId": "usernameAvailable", + "summary": "Check whether a username is free.", + "description": "**Core call:** `context.usernameAvailable`\n\n**Command line**\n\n```\nusername-available --username= [--challenge-id=]\n```\n\n", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "username-available", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.usernameAvailable", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [ + { + "name": "username", + "in": "query", + "required": true, + "description": "The name to check.", + "schema": { + "type": "string" + } + }, + { + "name": "challengeId", + "in": "query", + "required": false, + "description": "Supply after solving a CAPTCHA to retry the same check.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The name that was checked, echoed back." + }, + "available": { + "type": "boolean", + "description": "True when nobody holds this name. It is not reserved by asking." + } + }, + "required": [ + "username", + "available" + ] + } + } + } + }, + "default": { + "description": "USERNAME_ERROR, CHALLENGE_REQUIRED, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/fix-username": { + "get": { + "operationId": "fixUsername", + "summary": "Normalize a username.", + "description": "**Core call:** `context.fixUsername`\n\n**Command line**\n\n```\nfix-username --username=\n```\n\nApplies the same rules the login server does, so a caller can show the user what their name will actually be before creating an account.", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "fix-username", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.fixUsername", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [ + { + "name": "username", + "in": "query", + "required": true, + "description": "The name to normalize.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The normalized value. The input is not echoed." + } + }, + "required": [ + "username" + ] + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/check-password-rules": { + "get": { + "operationId": "checkPasswordRules", + "summary": "Score a candidate password.", + "description": "**Core call:** `context.checkPasswordRules`\n\n**Command line**\n\n```\ncheck-password-rules --password=\n```\n\n", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "check-password-rules", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.checkPasswordRules", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [ + { + "name": "password", + "in": "query", + "required": true, + "description": "The candidate password to score.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`EdgePasswordRules` from core: passed, tooShort, noNumber, noLowerCase, noUpperCase, secondsToCrack.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/fetch-login-messages": { + "get": { + "operationId": "fetchLoginMessages", + "summary": "Fetch login-server messages for every local user.", + "description": "**Core call:** `context.fetchLoginMessages`\n\n**Command line**\n\n```\nfetch-login-messages\n```\n\n", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "fetch-login-messages", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.fetchLoginMessages", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "200": { + "description": "`EdgeLoginMessages` from core, keyed by loginId; each value carries otpResetPending and pendingVouchers.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/request-otp-reset": { + "post": { + "operationId": "requestOtpReset", + "summary": "Request a 2FA reset.", + "description": "**Core call:** `context.requestOtpReset`\n\n**Command line**\n\n```\nrequest-otp-reset --username= --otp-reset-token=\n```\n\nStarts the timed reset a user falls back on after losing their authenticator.", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "request-otp-reset", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.requestOtpReset", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "200": { + "description": "When the reset completes if nobody cancels it.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "resetDate": { + "type": "string", + "description": "When 2FA will actually come off. The login server enforces a waiting period so the real owner has time to cancel." + } + }, + "required": [ + "resetDate" + ] + } + } + } + }, + "default": { + "description": "USERNAME_ERROR, BAD_REQUEST, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Whose 2FA to reset." + }, + "otpResetToken": { + "type": "string", + "description": "From `details.resetToken` on an `OTP_REQUIRED` error." + } + }, + "required": [ + "username", + "otpResetToken" + ] + } + } + } + } + } + }, + "/fetch-recovery-questions": { + "get": { + "operationId": "fetchRecoveryQuestions", + "summary": "Fetch a user’s recovery questions.", + "description": "**Core call:** `context.fetchRecovery2Questions`\n\n**Command line**\n\n```\nfetch-recovery-questions --recovery-key= --username=\n```\n\n", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "fetch-recovery-questions", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.fetchRecovery2Questions", + "x-core-note": "Our surface drops the `2` from the path, command and `recoveryKey` parameter; a future Recovery1 would be suffixed `V1`.", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [ + { + "name": "recoveryKey", + "in": "query", + "required": true, + "description": "From `change-recovery`, stored by the user out of band.", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "required": true, + "description": "Whose questions to fetch.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The questions in the order `login-with-recovery` expects the answers." + } + }, + "required": [ + "questions" + ] + } + } + } + }, + "default": { + "description": "USERNAME_ERROR, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/fetch-challenge": { + "post": { + "operationId": "fetchChallenge", + "summary": "Pre-fetch a CAPTCHA challenge.", + "description": "**Core call:** `context.fetchChallenge`\n\n**Command line**\n\n```\nfetch-challenge\n```\n\nLets a client solve a challenge before it hits `403 CHALLENGE_REQUIRED` mid-flow.", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "fetch-challenge", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "context.fetchChallenge", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "200": { + "description": "`challengeUri` is absent when the server considers the challenge already satisfied.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "challengeId": { + "type": "string", + "description": "Pass to the call that demanded a challenge once the user has solved it." + }, + "challengeUri": { + "type": "string", + "description": "Where to send the user to solve the CAPTCHA. Absent when the server issued a challenge that needs no interaction." + } + }, + "required": [ + "challengeId" + ] + } + } + } + }, + "default": { + "description": "NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/currency-configs": { + "get": { + "operationId": "currencyConfigs", + "summary": "List plugin ids usable for wallet creation.", + "description": "**Core call:** _none — Engine view of the enabled plugin set; core exposes `account.currencyConfig` per plugin instead._\n\n**Command line**\n\n```\ncurrency-configs\n```\n\nCurrency and accountbased plugins only — swap plugins are excluded.", + "tags": [ + "Device and usernames" + ], + "x-cli": { + "command": "currency-configs", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine view of the enabled plugin set; core exposes `account.currencyConfig` per plugin instead.", + "x-source": "src/cli/engine/routes/context.ts", + "parameters": [], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pluginIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Currency plugins this engine loaded." + } + }, + "required": [ + "pluginIds" + ] + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/login-with-password": { + "post": { + "operationId": "loginWithPassword", + "summary": "Log in with a password.", + "description": "**Core call:** `context.loginWithPassword`\n\n**Command line**\n\n```\nlogin-with-password [--otp=] [--otp-key=] [--challenge-id=] --username= --password=\n```\n\n", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "login-with-password", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": "context.loginWithPassword", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A session with `loginMethod: \"password\"`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "PASSWORD_ERROR, USERNAME_ERROR, OTP_REQUIRED, CHALLENGE_REQUIRED, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "description": "A current 2FA code." + }, + "otpKey": { + "type": "string", + "description": "The 2FA secret itself, instead of a code." + }, + "challengeId": { + "type": "string", + "description": "Supply after solving a CAPTCHA to retry the same request." + }, + "username": { + "type": "string", + "description": "The account name." + }, + "password": { + "type": "string", + "description": "The account password." + } + }, + "required": [ + "username", + "password" + ] + } + } + } + } + } + }, + "/login-with-pin": { + "post": { + "operationId": "loginWithPin", + "summary": "Log in with a device PIN.", + "description": "**Core call:** `context.loginWithPIN`\n\n**Command line**\n\n```\nlogin-with-pin [--otp=] [--otp-key=] [--challenge-id=] --username-or-login-id= --pin= [--use-login-id[=false]]\n```\n\nOnly works on a device that has already saved a PIN for the account.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "login-with-pin", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": "context.loginWithPIN", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A session with `loginMethod: \"pin\"`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "PASSWORD_ERROR, PIN_DISABLED, USERNAME_ERROR, BAD_REQUEST, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "description": "A current 2FA code." + }, + "otpKey": { + "type": "string", + "description": "The 2FA secret itself, instead of a code." + }, + "challengeId": { + "type": "string", + "description": "Supply after solving a CAPTCHA to retry the same request." + }, + "usernameOrLoginId": { + "type": "string", + "description": "A username, or a login id." + }, + "pin": { + "type": "string", + "description": "The device PIN." + }, + "useLoginId": { + "type": "boolean", + "description": "Treat the value as a login id." + } + }, + "required": [ + "usernameOrLoginId", + "pin" + ] + } + } + } + } + } + }, + "/login-with-key": { + "post": { + "operationId": "loginWithKey", + "summary": "Log in with an account login key.", + "description": "**Core call:** `context.loginWithKey`\n\n**Command line**\n\n```\nlogin-with-key [--otp=] [--otp-key=] [--challenge-id=] --username-or-login-id= --login-key= [--use-login-id[=false]]\n```\n\nThe key comes from `get-login-key` on an already-authenticated session.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "login-with-key", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": "context.loginWithKey", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A session with `loginMethod: \"key\"`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "PASSWORD_ERROR, USERNAME_ERROR, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "description": "A current 2FA code." + }, + "otpKey": { + "type": "string", + "description": "The 2FA secret itself, instead of a code." + }, + "challengeId": { + "type": "string", + "description": "Supply after solving a CAPTCHA to retry the same request." + }, + "usernameOrLoginId": { + "type": "string", + "description": "A username, or a login id." + }, + "loginKey": { + "type": "string", + "description": "From `get-login-key`." + }, + "useLoginId": { + "type": "boolean", + "description": "Treat the value as a login id." + } + }, + "required": [ + "usernameOrLoginId", + "loginKey" + ] + } + } + } + } + } + }, + "/login-with-recovery": { + "post": { + "operationId": "loginWithRecovery", + "summary": "Log in with recovery answers.", + "description": "**Core call:** `context.loginWithRecovery2`\n\n**Command line**\n\n```\nlogin-with-recovery [--otp=] [--otp-key=] [--challenge-id=] --recovery-key= --username= --answer= …\n```\n\nNeeds both the recovery key and the answers; neither works alone.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "login-with-recovery", + "flags": [ + { + "name": "answer", + "maps": "answers", + "repeat": true + } + ], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": "context.loginWithRecovery2", + "x-core-note": "Our surface drops the `2` from core's recovery2 naming, and calls the key `recoveryKey` to match what `change-recovery` returns.", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A session with `loginMethod: \"recovery\"`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "PASSWORD_ERROR, USERNAME_ERROR, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "description": "A current 2FA code." + }, + "otpKey": { + "type": "string", + "description": "The 2FA secret itself, instead of a code." + }, + "challengeId": { + "type": "string", + "description": "Supply after solving a CAPTCHA to retry the same request." + }, + "recoveryKey": { + "type": "string", + "description": "From `change-recovery`." + }, + "username": { + "type": "string", + "description": "The account name." + }, + "answers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "In the same order as the questions." + } + }, + "required": [ + "recoveryKey", + "username", + "answers" + ] + } + } + } + } + } + }, + "/create-account": { + "post": { + "operationId": "createAccount", + "summary": "Create an account.", + "description": "**Core call:** `context.createAccount`\n\n**Command line**\n\n```\ncreate-account [--otp=] [--otp-key=] [--challenge-id=] [--username=] [--password=] [--pin=]\n```\n\nEvery credential is optional over REST: omitting all three creates a light account with no username.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "create-account", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": "context.createAccount", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A session with `loginMethod: \"create\"`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "USERNAME_ERROR, CHALLENGE_REQUIRED, BAD_REQUEST, NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "description": "A current 2FA code." + }, + "otpKey": { + "type": "string", + "description": "The 2FA secret itself, instead of a code." + }, + "challengeId": { + "type": "string", + "description": "Supply after solving a CAPTCHA to retry the same request." + }, + "username": { + "type": "string", + "description": "The name to claim." + }, + "password": { + "type": "string", + "description": "The account password." + }, + "pin": { + "type": "string", + "description": "A device PIN to save." + } + } + } + } + } + } + } + }, + "/request-edge-login": { + "post": { + "operationId": "requestEdgeLogin", + "summary": "Start a QR login.", + "description": "**Core call:** `context.requestEdgeLogin`\n\n**Command line**\n\n```\nrequest-edge-login [--no-wait]\n```\n\nAsks the login server for a lobby another logged-in Edge device can approve. The returned `lobbyId` is what goes in the QR code.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "request-edge-login", + "flags": [], + "extra": [ + { + "name": "no-wait", + "kind": "boolean", + "required": false, + "doc": "Print the lobby and exit instead of polling, so the QR can be displayed while `poll-edge-login` watches the same handle from another process." + } + ], + "custom": true, + "preset": {}, + "notes": "Prints the pending login, then polls every 2s for up to 5 minutes. On `done` it stores the session. With `--no-wait` it returns immediately and `poll-edge-login` takes over." + }, + "x-core-call": "context.requestEdgeLogin", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "pendingId": { + "type": "string", + "description": "Same value as `objectId`, under the name the poll command takes." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the lobby closes and the QR code stops working." + }, + "lobbyId": { + "type": "string", + "description": "Lobby the phone connects to." + }, + "uri": { + "type": "string", + "description": "The `edge://` URI to render as a QR code for the phone to scan." + }, + "state": { + "type": "string", + "description": "How far the login has got: `pending` before the phone scans, `started` once it has, and `done` when `session` is filled in." + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Account that approved the login, known once the phone has scanned." + }, + "session": { + "anyOf": [ + { + "description": "{ sessionId: string; username: string" + }, + { + "description": "undefined; rootLoginId: string; loginMethod: \"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"create\"" + }, + { + "description": "\"recovery\"; autoLogoutSeconds: number; expiresAt: string" + }, + { + "description": "null; lastActivityAt: string; createdAt: string; }" + }, + { + "type": "null" + } + ], + "description": "The session, null until `state` is `done`." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why the login failed, set only when `state` is `error`." + } + }, + "required": [ + "objectId", + "pendingId", + "kind", + "expiresAt", + "lobbyId", + "uri", + "state", + "username", + "session", + "error" + ] + } + } + } + }, + "default": { + "description": "NETWORK_ERROR", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/pending-edge-login/{pendingId}": { + "get": { + "operationId": "pollEdgeLogin", + "summary": "Poll a pending QR login.", + "description": "**Core call:** _none — Engine state for an in-flight requestEdgeLogin; core exposes it as EdgePendingEdgeLogin properties._\n\n**Command line**\n\n```\npoll-edge-login \n```\n\nOnce `state` reaches `done` the engine has already created the session, so the response carries one ready to use.", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "poll-edge-login", + "positional": "pendingId", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine state for an in-flight requestEdgeLogin; core exposes it as EdgePendingEdgeLogin properties.", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [ + { + "name": "pendingId", + "in": "path", + "required": true, + "description": "The `pendingId` returned when the QR login was requested.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "pendingId": { + "type": "string", + "description": "Same value as `objectId`, under the name the poll command takes." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the lobby closes and the QR code stops working." + }, + "lobbyId": { + "type": "string", + "description": "Lobby the phone connects to." + }, + "uri": { + "type": "string", + "description": "The `edge://` URI to render as a QR code for the phone to scan." + }, + "state": { + "type": "string", + "description": "How far the login has got: `pending` before the phone scans, `started` once it has, and `done` when `session` is filled in." + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Account that approved the login, known once the phone has scanned." + }, + "session": { + "anyOf": [ + { + "description": "{ sessionId: string; username: string" + }, + { + "description": "undefined; rootLoginId: string; loginMethod: \"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"create\"" + }, + { + "description": "\"recovery\"; autoLogoutSeconds: number; expiresAt: string" + }, + { + "description": "null; lastActivityAt: string; createdAt: string; }" + }, + { + "type": "null" + } + ], + "description": "The session, null until `state` is `done`." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why the login failed, set only when `state` is `error`." + } + }, + "required": [ + "objectId", + "pendingId", + "kind", + "expiresAt", + "lobbyId", + "uri", + "state", + "username", + "session", + "error" + ] + } + } + } + }, + "default": { + "description": "PENDING_LOGIN_NOT_FOUND, OBJECT_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/pending-edge-login/cancel-request/{pendingId}": { + "post": { + "operationId": "cancelEdgeLogin", + "summary": "Cancel a pending QR login.", + "description": "**Core call:** `EdgePendingEdgeLogin.cancelRequest`\n\n**Command line**\n\n```\ncancel-request \n```\n\n", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "cancel-request", + "positional": "pendingId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "EdgePendingEdgeLogin.cancelRequest", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [ + { + "name": "pendingId", + "in": "path", + "required": true, + "description": "The `pendingId` returned when the QR login was requested.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "PENDING_LOGIN_NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/engine/sessions": { + "get": { + "operationId": "engineSessions", + "summary": "List active sessions.", + "description": "**Core call:** _none — The session registry is an engine construct; core has no multi-account session concept._\n\n**Command line**\n\n```\nengine-sessions\n```\n\n", + "tags": [ + "Login methods" + ], + "x-cli": { + "command": "engine-sessions", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "The session registry is an engine construct; core has no multi-account session concept.", + "x-source": "src/cli/engine/routes/login.ts", + "parameters": [], + "responses": { + "200": { + "description": "A bare array, not wrapped in a key. Each `sessionId` is truncated: this route needs no session, so a usable id here would be a credential anyone who can reach the engine could collect.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ sessionId: string; username: string" + }, + { + "description": "undefined; rootLoginId: string; loginMethod: \"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"create\"" + }, + { + "description": "\"recovery\"; autoLogoutSeconds: number; expiresAt: string" + }, + { + "description": "null; lastActivityAt: string; createdAt: string; }" + } + ] + } + } + } + } + }, + "default": { + "description": "Error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}": { + "get": { + "operationId": "accountInfo", + "summary": "Account and session summary.", + "description": "**Core call:** _none — Engine composite of the session record plus EdgeAccount properties._\n\n**Command line**\n\n```\naccount-info\n```\n\nSession fields are spread at the top level alongside the account's own properties — there is no nested `session` object.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "account-info", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine composite of the session record plus EdgeAccount properties.", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appId": { + "type": "string", + "description": "Application this session logged into." + }, + "created": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the account was created, null for accounts predating the field." + }, + "lastLogin": { + "type": "string", + "description": "The previous login, not this one." + }, + "loggedIn": { + "type": "boolean", + "description": "False once the account has been logged out; the session object outlives it briefly." + }, + "recoveryKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Present only while recovery is configured." + }, + "otpEnabled": { + "type": "boolean", + "description": "2FA is on for this account." + }, + "otpResetPending": { + "type": "boolean", + "description": "True while somebody has a reset pending against this account." + }, + "canDuressLogin": { + "type": "boolean", + "description": "A duress PIN is configured, so this account can be opened in duress mode." + }, + "isDuressAccount": { + "type": "boolean", + "description": "True when this very session is the duress account rather than the real one." + }, + "edgeLogin": { + "type": "boolean", + "description": "This account was reached by QR login." + }, + "keyLogin": { + "type": "boolean", + "description": "This session was reached with a login key." + }, + "newAccount": { + "type": "boolean", + "description": "This session created the account rather than logging into an existing one." + }, + "passwordLogin": { + "type": "boolean", + "description": "This session was reached with a password." + }, + "pinLogin": { + "type": "boolean", + "description": "This session was reached with a PIN." + }, + "recoveryLogin": { + "type": "boolean", + "description": "This session was reached by answering recovery questions." + } + }, + "required": [ + "appId", + "created", + "lastLogin", + "loggedIn", + "recoveryKey", + "otpEnabled", + "otpResetPending", + "canDuressLogin", + "isDuressAccount", + "edgeLogin", + "keyLogin", + "newAccount", + "passwordLogin", + "pinLogin", + "recoveryLogin" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/logout": { + "post": { + "operationId": "logout", + "summary": "Log out.", + "description": "**Core call:** `account.logout`\n\n**Command line**\n\n```\nlogout\n```\n\nEnds the session and drops it from the engine. Any subscription scoped to this account or its wallets is closed with it.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "logout", + "flags": [], + "extra": [], + "custom": true, + "preset": {}, + "notes": "Also clears the stored id from `session.json`." + }, + "x-core-call": "account.logout", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/touch": { + "post": { + "operationId": "touchSession", + "summary": "Keepalive.", + "description": "**Core call:** _none — Engine auto-logout timer; core has no idle concept._\n\n**Command line**\n\n```\ntouch\n```\n\nResets the idle auto-logout timer without doing any other work.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "touch", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine auto-logout timer; core has no idle concept.", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The session, with a refreshed `expiresAt`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Identifies this login. Every account-scoped call carries it, and the CLI stores the most recent one so commands can omit it." + }, + "username": { + "type": "string", + "description": "Absent for a light account, which has no username." + }, + "rootLoginId": { + "type": "string", + "description": "The account root, stable across appIds. Two sessions sharing it are the same account." + }, + "loginMethod": { + "anyOf": [ + { + "description": "\"create\"" + }, + { + "description": "\"edge\"" + }, + { + "description": "\"key\"" + }, + { + "description": "\"password\"" + }, + { + "description": "\"pin\"" + }, + { + "description": "\"recovery\"" + } + ], + "description": "How this session was established." + }, + "autoLogoutSeconds": { + "type": "number", + "description": "Idle time before the engine logs the account out. 0 disables it." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When auto-logout will fire, or null when it is disabled." + }, + "lastActivityAt": { + "type": "string", + "description": "Last call on this session, which is what auto-logout measures from." + }, + "createdAt": { + "type": "string", + "description": "When the login completed." + } + }, + "required": [ + "sessionId", + "rootLoginId", + "loginMethod", + "autoLogoutSeconds", + "expiresAt", + "lastActivityAt", + "createdAt" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/get-login-key": { + "get": { + "operationId": "getLoginKey", + "summary": "Read the account login key.", + "description": "**Core call:** `account.getLoginKey`\n\n**Command line**\n\n```\nget-login-key\n```\n\nThe key `login-with-key` takes. It grants full account access, so treat the output as secret.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "get-login-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getLoginKey", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "loginKey": { + "type": "string", + "description": "base58. Full account access — keep it safe." + } + }, + "required": [ + "loginKey" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/sync": { + "post": { + "operationId": "accountSync", + "summary": "Force an account data sync.", + "description": "**Core call:** `account.sync`\n\n**Command line**\n\n```\nsync\n```\n\nPushes and pulls the account repos immediately rather than waiting for the next scheduled sync.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "sync", + "flags": [], + "extra": [], + "custom": false, + "preset": {}, + "notes": "Named `sync` for the account; the wallet one is `wallet-sync`." + }, + "x-core-call": "account.sync", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/delete-remote-account": { + "post": { + "operationId": "deleteRemoteAccount", + "summary": "Permanently delete the remote account.", + "description": "**Core call:** `account.deleteRemoteAccount`\n\n**Command line**\n\n```\ndelete-remote-account --yes\n```\n\nIrreversible. The account is removed from the login server, and funds in its wallets are unrecoverable without the keys. The session is logged out afterwards.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "delete-remote-account", + "flags": [], + "extra": [ + { + "name": "yes", + "kind": "boolean", + "required": true, + "doc": "Confirms intent. Without it the command refuses to run." + } + ], + "custom": true, + "preset": {} + }, + "x-core-call": "account.deleteRemoteAccount", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wait-for-all-wallets": { + "post": { + "operationId": "waitForAllWallets", + "summary": "Wait for every wallet to finish loading.", + "description": "**Core call:** `account.waitForAllWallets`\n\n**Command line**\n\n```\nwait-for-all-wallets\n```\n\nWallets load in the background after login, so a list taken straight afterwards can be short. This resolves once each active wallet has either loaded or failed — balances may still be syncing afterwards.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "wait-for-all-wallets", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.waitForAllWallets", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/currency-wallets": { + "get": { + "operationId": "currencyWallets", + "summary": "List the account's wallets.", + "description": "**Core call:** `account.currencyWallets`\n\n**Command line**\n\n```\ncurrency-wallets [--filter=active|all|archived|hidden]\n```\n\n", + "tags": [ + "Session" + ], + "x-cli": { + "command": "currency-wallets", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.currencyWallets", + "x-core-note": "Filtered by account.activeWalletIds / archivedWalletIds / hiddenWalletIds.", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "filter", + "in": "query", + "required": false, + "description": "Which of the account’s wallet lists to read. Defaults to `active`.", + "schema": { + "anyOf": [ + { + "description": "\"active\"" + }, + { + "description": "\"all\"" + }, + { + "description": "\"archived\"" + }, + { + "description": "\"hidden\"" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currencyWallets": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ walletId: string; id: string; type: string; name: string" + }, + { + "description": "null; pluginId: string; currencyCode: string; fiatCurrencyCode: string; blockHeight: number; syncStatus: unknown; syncRatio: string" + }, + { + "description": "undefined; paused: boolean; imported: boolean" + }, + { + "description": "undefined; created: string" + }, + { + "description": "null; enabledTokenIds: string[]; detectedTokenIds: string[]; unactivatedTokenIds: string[]; }" + } + ] + }, + "description": "Every wallet in the account, including paused ones." + } + }, + "required": [ + "currencyWallets" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/create-currency-wallet": { + "post": { + "operationId": "createCurrencyWallet", + "summary": "Create a currency wallet.", + "description": "**Core call:** `account.createCurrencyWallet`\n\n**Command line**\n\n```\ncreate-currency-wallet --wallet-type= [--name=] [--import-text=]\n```\n\n", + "tags": [ + "Session" + ], + "x-cli": { + "command": "create-currency-wallet", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.createCurrencyWallet", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The full wallet id. Commands taking a wallet accept any unique prefix." + }, + "id": { + "type": "string", + "description": "Same value as `walletId`; core exposes both names." + }, + "type": { + "type": "string", + "description": "Key type, such as `wallet:bitcoin`." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "User-assigned name, null until one is set." + }, + "pluginId": { + "type": "string", + "description": "Currency plugin backing this wallet." + }, + "currencyCode": { + "type": "string", + "description": "Ticker for the native asset." + }, + "fiatCurrencyCode": { + "type": "string", + "description": "Fiat the wallet reports value in, as `iso:USD`." + }, + "blockHeight": { + "type": "number", + "description": "Chain height this wallet has seen." + }, + "syncStatus": { + "description": "`EdgeWalletSyncStatus` from core." + }, + "syncRatio": { + "type": "string", + "description": "Sync progress as a percentage, for display." + }, + "paused": { + "type": "boolean", + "description": "True while the engine is not syncing this wallet." + }, + "imported": { + "type": "boolean", + "description": "True when the keys came from an import rather than being generated here." + }, + "created": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the wallet was created, null for wallets predating the field." + }, + "enabledTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tokens the user turned on." + }, + "detectedTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tokens found on-chain that are not enabled yet." + }, + "unactivatedTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Enabled tokens still awaiting on-chain activation." + } + }, + "required": [ + "walletId", + "id", + "type", + "name", + "pluginId", + "currencyCode", + "fiatCurrencyCode", + "blockHeight", + "syncStatus", + "paused", + "created", + "enabledTokenIds", + "detectedTokenIds", + "unactivatedTokenIds" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletType": { + "type": "string", + "description": "From `currency-configs`, e.g. `wallet:bitcoin`." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "importText": { + "type": "string", + "description": "Seed or key text to import instead of generating." + } + }, + "required": [ + "walletType" + ] + } + } + } + } + } + }, + "/account/{sessionId}/create-currency-wallets": { + "post": { + "operationId": "createCurrencyWallets", + "summary": "Create several wallets at once.", + "description": "**Core call:** `account.createCurrencyWallets`\n\n**Command line**\n\n```\ncreate-currency-wallets --create-wallets=''\n```\n\nPartial success is normal: each entry reports its own outcome, and one failure does not roll back the others.", + "tags": [ + "Session" + ], + "x-cli": { + "command": "create-currency-wallets", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.createCurrencyWallets", + "x-source": "src/cli/engine/routes/account.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": {}, + "description": "Mirrors core’s EdgeResult[]: `{ ok, wallet }` or `{ ok: false, error }`." + } + }, + "required": [ + "results" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "createWallets": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ walletType: string; name: string" + }, + { + "description": "undefined; fiatCurrencyCode: string" + }, + { + "description": "undefined; }" + } + ] + }, + "description": "`EdgeCreateCurrencyWallet[]`: walletType, plus optional name and fiatCurrencyCode." + } + }, + "required": [ + "createWallets" + ] + } + } + } + } + } + }, + "/account/{sessionId}/local-settings": { + "get": { + "operationId": "localSettings", + "summary": "Local settings.", + "description": "**Core call:** _none — GUI code (src/util/localAccountSettings), reached through account.localDisklet._\n\n**Command line**\n\n```\nlocal-settings\n```\n\nDevice-local account settings, stored in `Settings.json` on `account.localDisklet`. They are not synced — a phone and a CLI keep separate copies unless they share an Edge data directory.", + "tags": [ + "Local settings" + ], + "x-cli": { + "command": "local-settings", + "flags": [], + "extra": [], + "custom": true, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "GUI code (src/util/localAccountSettings), reached through account.localDisklet.", + "x-source": "src/cli/engine/routes/localSettings.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "spamFilterOn": { + "type": "boolean", + "description": "Hide spam transactions in `get-transactions` results. Defaults to `true`, matching the GUI. The filter hides rows; it never changes stored metadata." + } + }, + "required": [ + "spamFilterOn" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/change-local-settings": { + "post": { + "operationId": "changeLocalSettings", + "summary": "Change local settings.", + "description": "**Core call:** _none — GUI code (src/util/localAccountSettings)._\n\n**Command line**\n\n```\nlocal-settings --spam-filter-on=true|false\n```\n\nWrites device-local account settings. Every option is a field on the body; `spamFilterOn` is the only one today, and new options are added alongside it.", + "tags": [ + "Local settings" + ], + "x-cli": { + "command": "local-settings", + "flags": [], + "extra": [], + "custom": true, + "preset": {}, + "notes": "With no flag the command reads; with one it writes." + }, + "x-core-call": null, + "x-core-note": "GUI code (src/util/localAccountSettings).", + "x-source": "src/cli/engine/routes/localSettings.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "spamFilterOn": { + "type": "boolean", + "description": "Hide spam transactions in `get-transactions` results. Defaults to `true`, matching the GUI. The filter hides rows; it never changes stored metadata." + } + }, + "required": [ + "spamFilterOn" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "spamFilterOn": { + "type": "boolean", + "description": "Hide spam transactions in `get-transactions` results. Defaults to `true`, matching the GUI. The filter hides rows; it never changes stored metadata." + } + }, + "required": [ + "spamFilterOn" + ] + } + } + } + } + } + }, + "/account/{sessionId}/change-password": { + "post": { + "operationId": "changePassword", + "summary": "Set or change the password.", + "description": "**Core call:** `account.changePassword`\n\n**Command line**\n\n```\nchange-password --password=\n```\n\nThe login server enforces its own rules; `check-password-rules` scores a candidate first.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "change-password", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.changePassword", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "description": "The new password." + } + }, + "required": [ + "password" + ] + } + } + } + } + } + }, + "/account/{sessionId}/delete-password": { + "post": { + "operationId": "deletePassword", + "summary": "Remove password login.", + "description": "**Core call:** `account.deletePassword`\n\n**Command line**\n\n```\ndelete-password\n```\n\nThe account keeps its other login methods; only the password stops working.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "delete-password", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.deletePassword", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/check-password": { + "post": { + "operationId": "checkPassword", + "summary": "Verify a password.", + "description": "**Core call:** `account.checkPassword`\n\n**Command line**\n\n```\ncheck-password --password=\n```\n\nChecks without changing anything, which is how a caller gates a destructive action behind a re-entry prompt.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "check-password", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.checkPassword", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "False for a wrong password — not an error response." + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "description": "The account password." + } + }, + "required": [ + "password" + ] + } + } + } + } + } + }, + "/account/{sessionId}/get-pin": { + "get": { + "operationId": "getPin", + "summary": "Read the account PIN.", + "description": "**Core call:** `account.getPin`\n\n**Command line**\n\n```\nget-pin\n```\n\nReturns the PIN itself, not a status flag, so treat the output as secret.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "get-pin", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getPin", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Null when no PIN is set." + } + }, + "required": [ + "pin" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/change-pin": { + "post": { + "operationId": "changePin", + "summary": "Set or change the PIN.", + "description": "**Core call:** `account.changePin`\n\n**Command line**\n\n```\nchange-pin --pin= [--enable-login[=false]] [--for-duress-account[=false]]\n```\n\n", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "change-pin", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.changePin", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin2Key": { + "type": "string", + "description": "The new PIN login key core returns." + } + }, + "required": [ + "pin2Key" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin": { + "type": "string", + "description": "The new PIN." + }, + "enableLogin": { + "type": "boolean", + "description": "Allow logging in with this PIN on this device." + }, + "forDuressAccount": { + "type": "boolean", + "description": "Act on the duress account rather than the real one." + } + }, + "required": [ + "pin" + ] + } + } + } + } + } + }, + "/account/{sessionId}/delete-pin": { + "post": { + "operationId": "deletePin", + "summary": "Remove the PIN.", + "description": "**Core call:** `account.deletePin`\n\n**Command line**\n\n```\ndelete-pin\n```\n\nPIN login stops working on this device; other methods are untouched.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "delete-pin", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.deletePin", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/check-pin": { + "post": { + "operationId": "checkPin", + "summary": "Verify a PIN.", + "description": "**Core call:** `account.checkPin`\n\n**Command line**\n\n```\ncheck-pin --pin= [--for-duress-account[=false]]\n```\n\n", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "check-pin", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.checkPin", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "False for a wrong PIN — not an error response." + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pin": { + "type": "string", + "description": "The device PIN, usually four digits." + }, + "forDuressAccount": { + "type": "boolean", + "description": "Act on the duress account rather than the real one." + } + }, + "required": [ + "pin" + ] + } + } + } + } + } + }, + "/account/{sessionId}/change-username": { + "post": { + "operationId": "changeUsername", + "summary": "Change the username.", + "description": "**Core call:** `account.changeUsername`\n\n**Command line**\n\n```\nchange-username --username= [--password=]\n```\n\nThe old name is released, so it becomes available to anyone else.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "change-username", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.changeUsername", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "USERNAME_ERROR, BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "The new username." + }, + "password": { + "type": "string", + "description": "Required by core when the account has a password." + } + }, + "required": [ + "username" + ] + } + } + } + } + } + }, + "/account/{sessionId}/change-recovery": { + "post": { + "operationId": "changeRecovery", + "summary": "Set recovery questions and answers.", + "description": "**Core call:** `account.changeRecovery`\n\n**Command line**\n\n```\nchange-recovery --question= … --answer= …\n```\n\nThe returned key is half of the credential: without it the answers alone cannot recover the account, so it has to be stored somewhere else.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "change-recovery", + "flags": [ + { + "name": "question", + "maps": "questions", + "repeat": true + }, + { + "name": "answer", + "maps": "answers", + "repeat": true + } + ], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.changeRecovery", + "x-core-note": "Our surface drops the `2` from core's recovery2 naming; a future Recovery1 would be suffixed `V1`.", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recoveryKey": { + "type": "string", + "description": "Store this out of band. `login-with-recovery` needs it alongside the answers." + } + }, + "required": [ + "recoveryKey" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The questions to ask." + }, + "answers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Same length and order as `questions`." + } + }, + "required": [ + "questions", + "answers" + ] + } + } + } + } + } + }, + "/account/{sessionId}/delete-recovery": { + "post": { + "operationId": "deleteRecovery", + "summary": "Disable recovery login.", + "description": "**Core call:** `account.deleteRecovery`\n\n**Command line**\n\n```\ndelete-recovery\n```\n\nThe existing recovery key stops working.", + "tags": [ + "Credentials" + ], + "x-cli": { + "command": "delete-recovery", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.deleteRecovery", + "x-source": "src/cli/engine/routes/credentials.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/otp-key": { + "get": { + "operationId": "otpKey", + "summary": "Read the 2FA secret and reset state.", + "description": "**Core call:** `account.otpKey`\n\n**Command line**\n\n```\notp-key\n```\n\n", + "tags": [ + "Two-factor authentication" + ], + "x-cli": { + "command": "otp-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.otpKey", + "x-core-note": "Also carries account.otpResetDate.", + "x-source": "src/cli/engine/routes/otp.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otpKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Null when 2FA is off. The 2FA secret itself. Secret material — record it safely." + }, + "otpResetDate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Set once somebody has requested a reset; cancel it with `cancel-otp-reset`." + } + }, + "required": [ + "otpKey", + "otpResetDate" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/enable-otp": { + "post": { + "operationId": "enableOtp", + "summary": "Enable 2FA.", + "description": "**Core call:** `account.enableOtp`\n\n**Command line**\n\n```\nenable-otp [--timeout=]\n```\n\nRecord the returned key before leaving the terminal: it is the only copy.", + "tags": [ + "Two-factor authentication" + ], + "x-cli": { + "command": "enable-otp", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.enableOtp", + "x-source": "src/cli/engine/routes/otp.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otpKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The new secret. The 2FA secret itself. Secret material — record it safely." + } + }, + "required": [ + "otpKey" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "number", + "description": "How long a reset request must wait before it completes, in seconds. Core supplies the default when omitted." + } + } + } + } + } + } + } + }, + "/account/{sessionId}/disable-otp": { + "post": { + "operationId": "disableOtp", + "summary": "Disable 2FA.", + "description": "**Core call:** `account.disableOtp`\n\n**Command line**\n\n```\ndisable-otp\n```\n\nLogins stop requiring a code immediately.", + "tags": [ + "Two-factor authentication" + ], + "x-cli": { + "command": "disable-otp", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.disableOtp", + "x-source": "src/cli/engine/routes/otp.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/cancel-otp-reset": { + "post": { + "operationId": "cancelOtpReset", + "summary": "Cancel a pending 2FA reset.", + "description": "**Core call:** `account.cancelOtpReset`\n\n**Command line**\n\n```\ncancel-otp-reset\n```\n\nThe defence against somebody else requesting a reset on your account: as long as you cancel before the timer runs out, their reset never lands.", + "tags": [ + "Two-factor authentication" + ], + "x-cli": { + "command": "cancel-otp-reset", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.cancelOtpReset", + "x-source": "src/cli/engine/routes/otp.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/repair-otp": { + "post": { + "operationId": "repairOtp", + "summary": "Re-point the account at a known 2FA secret.", + "description": "**Core call:** `account.repairOtp`\n\n**Command line**\n\n```\nrepair-otp --otp-key=\n```\n\nFor a device whose stored secret has drifted from the server's.", + "tags": [ + "Two-factor authentication" + ], + "x-cli": { + "command": "repair-otp", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.repairOtp", + "x-source": "src/cli/engine/routes/otp.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "OTP_REQUIRED, BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "otpKey": { + "type": "string", + "description": "The secret the account should use." + } + }, + "required": [ + "otpKey" + ] + } + } + } + } + } + }, + "/account/{sessionId}/pending-vouchers": { + "get": { + "operationId": "pendingVouchers", + "summary": "List pending 2FA vouchers.", + "description": "**Core call:** `account.pendingVouchers`\n\n**Command line**\n\n```\npending-vouchers\n```\n\nWhen 2FA blocks a login, the login server issues a voucher that an already-trusted device can approve or reject.", + "tags": [ + "Vouchers" + ], + "x-cli": { + "command": "pending-vouchers", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.pendingVouchers", + "x-source": "src/cli/engine/routes/vouchers.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pendingVouchers": { + "type": "array", + "items": {}, + "description": "`EdgePendingVoucher[]`: voucherId, activates, created, deviceDescription, ipDescription." + } + }, + "required": [ + "pendingVouchers" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/approve-voucher": { + "post": { + "operationId": "approveVoucher", + "summary": "Approve a voucher.", + "description": "**Core call:** `account.approveVoucher`\n\n**Command line**\n\n```\napprove-voucher --voucher-id=\n```\n\nLets the waiting device finish logging in.", + "tags": [ + "Vouchers" + ], + "x-cli": { + "command": "approve-voucher", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.approveVoucher", + "x-source": "src/cli/engine/routes/vouchers.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "voucherId": { + "type": "string", + "description": "From `pending-vouchers`, or an `OTP_REQUIRED` error’s `details.voucherId`." + } + }, + "required": [ + "voucherId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/reject-voucher": { + "post": { + "operationId": "rejectVoucher", + "summary": "Reject a voucher.", + "description": "**Core call:** `account.rejectVoucher`\n\n**Command line**\n\n```\nreject-voucher --voucher-id=\n```\n\nDenies the waiting device. The login it was issued for cannot complete.", + "tags": [ + "Vouchers" + ], + "x-cli": { + "command": "reject-voucher", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.rejectVoucher", + "x-source": "src/cli/engine/routes/vouchers.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "voucherId": { + "type": "string", + "description": "From `pending-vouchers`, or an `OTP_REQUIRED` error’s `details.voucherId`." + } + }, + "required": [ + "voucherId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/fetch-lobby/{lobbyId}": { + "get": { + "operationId": "fetchLobby", + "summary": "Inspect a login request.", + "description": "**Core call:** `account.fetchLobby`\n\n**Command line**\n\n```\nfetch-lobby \n```\n\nThe other side of `request-edge-login`: shows who is asking, so a human can decide before approving.", + "tags": [ + "Approving a login" + ], + "x-cli": { + "command": "fetch-lobby", + "positional": "lobbyId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.fetchLobby", + "x-source": "src/cli/engine/routes/lobby.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "lobbyId", + "in": "path", + "required": true, + "description": "From the QR code, or an `edge://edge/` link.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lobbyId": { + "type": "string", + "description": "The lobby that was fetched, echoed back." + }, + "loginRequest": { + "anyOf": [ + { + "description": "{ appId: string; displayName: string; displayImageDarkUrl: string" + }, + { + "description": "null; displayImageLightUrl: string" + }, + { + "description": "null; }" + }, + { + "type": "null" + } + ], + "description": "Null when the lobby carries no pending login request." + } + }, + "required": [ + "lobbyId", + "loginRequest" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/approve-login-request/{lobbyId}": { + "post": { + "operationId": "approveLoginRequest", + "summary": "Approve a login request.", + "description": "**Core call:** `EdgeLoginRequest.approve`\n\n**Command line**\n\n```\napprove-login-request \n```\n\nGrants the requesting device access to this account.", + "tags": [ + "Approving a login" + ], + "x-cli": { + "command": "approve-login-request", + "positional": "lobbyId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "EdgeLoginRequest.approve", + "x-core-note": "Reached through account.fetchLobby(lobbyId).loginRequest.", + "x-source": "src/cli/engine/routes/lobby.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "lobbyId", + "in": "path", + "required": true, + "description": "From the QR code, or an `edge://edge/` link.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "Always true; a failure arrives as an error envelope." + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "default": { + "description": "NO_LOGIN_REQUEST, BAD_REQUEST, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/all-keys": { + "get": { + "operationId": "allKeys", + "summary": "List every key in the account.", + "description": "**Core call:** `account.allKeys`\n\n**Command line**\n\n```\nall-keys\n```\n\nIncludes archived and deleted keys, unlike `currency-wallets`.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "all-keys", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.allKeys", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allKeys": { + "type": "array", + "items": {}, + "description": "`EdgeWalletInfoFull[]`: id, type, keys, archived, deleted, hidden, sortIndex." + } + }, + "required": [ + "allKeys" + ] + } + } + } + }, + "default": { + "description": "INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/create-wallet": { + "post": { + "operationId": "createWallet", + "summary": "Create a wallet from raw key JSON.", + "description": "**Core call:** `account.createWallet`\n\n**Command line**\n\n```\ncreate-wallet --type= [--keys='']\n```\n\nThe import path. Use `create-currency-wallet` to make a fresh wallet with generated keys.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "create-wallet", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.createWallet", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The new wallet. Its keys are already saved." + } + }, + "required": [ + "walletId" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Wallet type, e.g. `wallet:bitcoin`." + }, + "keys": { + "description": "Plugin key material. Omit to let core generate it." + } + }, + "required": [ + "type" + ] + } + } + } + } + } + }, + "/account/{sessionId}/get-wallet-info": { + "get": { + "operationId": "getWalletInfo", + "summary": "Read one wallet's key info.", + "description": "**Core call:** `account.getWalletInfo`\n\n**Command line**\n\n```\nget-wallet-info --id=\n```\n\n", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "get-wallet-info", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getWalletInfo", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "query", + "required": true, + "description": "The key id, from `all-keys`. Base64, like a wallet id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`EdgeWalletInfoFull`, verbatim from core — including the `keys` object.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/get-raw-private-key": { + "get": { + "operationId": "getRawPrivateKey", + "summary": "Read raw private key material.", + "description": "**Core call:** `account.getRawPrivateKey`\n\n**Command line**\n\n```\nget-raw-private-key --wallet-id=\n```\n\nSecret. Whatever the plugin stores — seed, mnemonic, xpriv.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "get-raw-private-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getRawPrivateKey", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The plugin’s key object, at the top level.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/get-raw-public-key": { + "get": { + "operationId": "getRawPublicKey", + "summary": "Read raw public key material.", + "description": "**Core call:** `account.getRawPublicKey`\n\n**Command line**\n\n```\nget-raw-public-key --wallet-id=\n```\n\n", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "get-raw-public-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getRawPublicKey", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The plugin’s public key object.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/get-display-private-key": { + "get": { + "operationId": "getDisplayPrivateKey", + "summary": "Export the private key for display.", + "description": "**Core call:** `account.getDisplayPrivateKey`\n\n**Command line**\n\n```\nget-display-private-key --wallet-id=\n```\n\nSecret. The human-facing form — WIF, seed phrase, whatever the plugin shows on its export screen.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "get-display-private-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getDisplayPrivateKey", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The displayable private key." + } + }, + "required": [ + "key" + ] + } + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/get-display-public-key": { + "get": { + "operationId": "getDisplayPublicKey", + "summary": "Export the public key for display.", + "description": "**Core call:** `account.getDisplayPublicKey`\n\n**Command line**\n\n```\nget-display-public-key --wallet-id=\n```\n\nThe xpub or equivalent — safe to share for watch-only use.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "get-display-public-key", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.getDisplayPublicKey", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The displayable public key." + } + }, + "required": [ + "key" + ] + } + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/list-splittable-wallet-types": { + "get": { + "operationId": "listSplittableWalletTypes", + "summary": "List chains a wallet can split into.", + "description": "**Core call:** `account.listSplittableWalletTypes`\n\n**Command line**\n\n```\nlist-splittable-wallet-types --wallet-id=\n```\n\nForked-chain support: which wallet types can be derived from these keys.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "list-splittable-wallet-types", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.listSplittableWalletTypes", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types valid for `split`." + } + }, + "required": [ + "walletTypes" + ] + } + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/change-wallet-states": { + "post": { + "operationId": "changeWalletStates", + "summary": "Archive, delete, hide, or reorder wallets.", + "description": "**Core call:** `account.changeWalletStates`\n\n**Command line**\n\n```\nchange-wallet-states [--wallet-states=''] --wallet-id= [--archived=] [--deleted=] [--hidden=] [--sort-index=]\n```\n\nThe canonical backend for every wallet flag; there are no separate archive, unarchive or undelete verbs.", + "tags": [ + "Keys" + ], + "x-cli": { + "command": "change-wallet-states", + "flags": [], + "extra": [ + { + "name": "wallet-id", + "kind": "string", + "required": true, + "doc": "The wallet to change. The command makes it the key of a single-entry `walletStates` map." + }, + { + "name": "archived", + "kind": "boolstr", + "required": false, + "doc": "Hide from the active list." + }, + { + "name": "deleted", + "kind": "boolstr", + "required": false, + "doc": "Mark deleted." + }, + { + "name": "hidden", + "kind": "boolstr", + "required": false, + "doc": "Hide from the wallet picker." + }, + { + "name": "sort-index", + "kind": "string", + "required": false, + "doc": "Position in the wallet list." + } + ], + "custom": true, + "preset": {}, + "notes": "The command builds a single-wallet `walletStates` map from these flags, and needs at least one." + }, + "x-core-call": "account.changeWalletStates", + "x-source": "src/cli/engine/routes/keys.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletStates": { + "anyOf": [ + { + "description": "{ [keys: string]: { archived: boolean" + }, + { + "description": "undefined; deleted: boolean" + }, + { + "description": "undefined; hidden: boolean" + }, + { + "description": "undefined; sortIndex: number" + }, + { + "description": "undefined; }; }" + } + ], + "description": "`EdgeWalletStates`: wallet ids to the flags being changed." + } + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet": { + "get": { + "operationId": "walletInfo", + "summary": "Wallet detail.", + "description": "**Core call:** _none — Engine composite of EdgeCurrencyWallet properties plus its EdgeCurrencyConfig token map._\n\n**Command line**\n\n```\nwallet-info --wallet-id=\n```\n\n", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "wallet-info", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine composite of EdgeCurrencyWallet properties plus its EdgeCurrencyConfig token map.", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Every WalletSummary field, plus denominations and walletSettings. `allTokens` is not here — `wallet-tokens` exists to carry it, and returning it from both sent the same map twice in a session that calls both.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/rename-wallet": { + "post": { + "operationId": "renameWallet", + "summary": "Rename a wallet.", + "description": "**Core call:** `wallet.renameWallet`\n\n**Command line**\n\n```\nrename-wallet --wallet-id= --name=\n```\n\n", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "rename-wallet", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.renameWallet", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "name": { + "type": "string", + "description": "The new display name." + } + }, + "required": [ + "walletId", + "name" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/set-fiat-currency-code": { + "post": { + "operationId": "setFiatCurrencyCode", + "summary": "Change a wallet's fiat currency.", + "description": "**Core call:** `wallet.setFiatCurrencyCode`\n\n**Command line**\n\n```\nset-fiat-currency-code --wallet-id= --fiat-currency-code=\n```\n\nAffects how balances and history are priced, not the asset itself.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "set-fiat-currency-code", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.setFiatCurrencyCode", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "fiatCurrencyCode": { + "type": "string", + "description": "e.g. `iso:EUR`." + } + }, + "required": [ + "walletId", + "fiatCurrencyCode" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/change-paused": { + "post": { + "operationId": "changePaused", + "summary": "Pause or resume a wallet engine.", + "description": "**Core call:** `wallet.changePaused`\n\n**Command line**\n\n```\nchange-paused --wallet-id= --paused=true|false\n```\n\nA paused wallet stops syncing, which is how a caller quiets a chain it does not currently care about.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "change-paused", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.changePaused", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "paused": { + "type": "boolean", + "description": "True to stop syncing." + } + }, + "required": [ + "walletId", + "paused" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/sync": { + "post": { + "operationId": "walletSync", + "summary": "Nudge one wallet to sync.", + "description": "**Core call:** `wallet.sync`\n\n**Command line**\n\n```\nwallet-sync --wallet-id=\n```\n\n", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "wallet-sync", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.sync", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/resync-blockchain": { + "post": { + "operationId": "resyncBlockchain", + "summary": "Rescan the blockchain from scratch.", + "description": "**Core call:** `wallet.resyncBlockchain`\n\n**Command line**\n\n```\nresync-blockchain --wallet-id=\n```\n\nDrops cached chain state and re-scans. Expensive, and the wallet reports an incomplete balance until it finishes.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "resync-blockchain", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.resyncBlockchain", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/split": { + "post": { + "operationId": "splitWallet", + "summary": "Split a wallet into another chain.", + "description": "**Core call:** `wallet.split`\n\n**Command line**\n\n```\nsplit --wallet-id= --split-wallets=''\n```\n\nForked-chain support: derive a wallet of a different type from the same keys. `list-splittable-wallet-types` says which are valid.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "split", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.split", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": {}, + "description": "Per-entry outcomes, like batch create." + } + }, + "required": [ + "results" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "splitWallets": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ walletType: string; name: string" + }, + { + "description": "undefined; fiatCurrencyCode: string" + }, + { + "description": "undefined; }" + } + ] + }, + "description": "`EdgeSplitCurrencyWallet[]`: walletType, plus optional name and fiatCurrencyCode." + } + }, + "required": [ + "walletId", + "splitWallets" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/dump-data": { + "get": { + "operationId": "dumpData", + "summary": "Dump wallet engine state.", + "description": "**Core call:** `wallet.dumpData`\n\n**Command line**\n\n```\ndump-data --wallet-id=\n```\n\nPlugin-defined debug output. Shape varies by plugin and can be very large.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "dump-data", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.dumpData", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`EdgeDataDump`, straight from the plugin.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/balance-map": { + "get": { + "operationId": "balanceMap", + "summary": "Balances for every asset in the wallet.", + "description": "**Core call:** `wallet.balanceMap`\n\n**Command line**\n\n```\nbalance-map --wallet-id= [--token-id=]\n```\n\nThe native currency plus every enabled token.", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "balance-map", + "flags": [], + "extra": [ + { + "name": "token-id", + "kind": "string", + "required": false, + "doc": "Client-side filter; core has no single-balance accessor." + } + ], + "custom": true, + "preset": {} + }, + "x-core-call": "wallet.balanceMap", + "x-core-note": "Rendered as an array, with currencyCode and displayAmount added from the wallet's denominations.", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "balances": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ tokenId: string" + }, + { + "description": "null; currencyCode: string; nativeAmount: string; displayAmount: string" + }, + { + "description": "null; unknownToken: boolean; }" + } + ] + }, + "description": "One entry per asset the wallet holds, native coin first: `tokenId`, `currencyCode`, `nativeAmount`, `displayAmount` and `unknownToken`. A token the plugin reports a balance for but whose config it no longer carries is listed with `unknownToken: true` and a null `displayAmount`, rather than failing the whole wallet." + } + }, + "required": [ + "balances" + ] + } + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/get-addresses": { + "get": { + "operationId": "getAddresses", + "summary": "Receive addresses.", + "description": "**Core call:** `wallet.getAddresses`\n\n**Command line**\n\n```\nget-addresses --wallet-id= [--token-id=] [--force-index=]\n```\n\n", + "tags": [ + "Wallet state" + ], + "x-cli": { + "command": "get-addresses", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.getAddresses", + "x-source": "src/cli/engine/routes/wallets.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + }, + { + "name": "tokenId", + "in": "query", + "required": false, + "description": "Defaults to the native asset.", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "forceIndex", + "in": "query", + "required": false, + "description": "Derive at a specific index.", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "addresses": { + "type": "array", + "items": {}, + "description": "`EdgeAddress[]`: addressType, publicAddress, nativeBalance." + } + }, + "required": [ + "addresses" + ] + } + } + } + }, + "default": { + "description": "TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/tokens": { + "get": { + "operationId": "walletTokens", + "summary": "List a wallet's tokens.", + "description": "**Core call:** _none — Engine composite of the EdgeCurrencyConfig token maps plus wallet.enabledTokenIds and wallet.detectedTokenIds._\n\n**Command line**\n\n```\nwallet-tokens --wallet-id=\n```\n\n\"Enabled\" tokens are the ones the wallet syncs balances for; \"detected\" ones were seen on-chain but are not yet enabled.", + "tags": [ + "Tokens" + ], + "x-cli": { + "command": "wallet-tokens", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine composite of the EdgeCurrencyConfig token maps plus wallet.enabledTokenIds and wallet.detectedTokenIds.", + "x-source": "src/cli/engine/routes/tokens.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allTokens": { + "description": "`EdgeToken` by tokenId: everything the plugin ships with, plus this account’s own. `builtinTokens` is not returned separately — it is this map minus `customTokens`, and sending both wrote the whole built-in list down the socket twice." + }, + "customTokens": { + "description": "`EdgeToken` by tokenId: tokens this account added by hand." + }, + "enabledTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which of the above the wallet is actually tracking." + }, + "detectedTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Seen on-chain but not enabled, so their balances are not synced." + } + }, + "required": [ + "allTokens", + "customTokens", + "enabledTokenIds", + "detectedTokenIds" + ] + } + } + } + }, + "default": { + "description": "WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/change-enabled-token-ids": { + "post": { + "operationId": "changeEnabledTokenIds", + "summary": "Set the enabled token set.", + "description": "**Core call:** `wallet.changeEnabledTokenIds`\n\n**Command line**\n\n```\nchange-enabled-token-ids --wallet-id= --token-ids='' [--add=] [--remove=]\n```\n\nAbsolute: anything missing from `tokenIds` is disabled. Core has only this setter, so there is no add or remove call.", + "tags": [ + "Tokens" + ], + "x-cli": { + "command": "change-enabled-token-ids", + "flags": [], + "extra": [ + { + "name": "add", + "kind": "repeat", + "required": false, + "doc": "Read the current set, add this id, write it back." + }, + { + "name": "remove", + "kind": "repeat", + "required": false, + "doc": "Read the current set, drop this id, write it back." + } + ], + "custom": true, + "preset": {} + }, + "x-core-call": "wallet.changeEnabledTokenIds", + "x-source": "src/cli/engine/routes/tokens.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabledTokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The wallet’s enabled tokens after the change, not just what changed." + } + }, + "required": [ + "enabledTokenIds" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "tokenIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The complete desired set." + } + }, + "required": [ + "walletId", + "tokenIds" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/get-transactions": { + "get": { + "operationId": "getTransactions", + "summary": "List or export a wallet's transactions.", + "description": "**Core call:** `wallet.getTransactions`\n\n**Command line**\n\n```\nget-transactions --wallet-id= [--token-id=] [--limit=] [--offset=] [--save-export-prefs[=false]] [--start-date=] [--end-date=] [--search-string=] [--spam-threshold=] [--fiat=] [--export-format=] [--bitwave-account=] [--out=]\n```\n\nReads history, overlays the display metadata the GUI shows, fills historical fiat, and optionally formats the result — all on this one call.", + "tags": [ + "Transactions" + ], + "x-cli": { + "command": "get-transactions", + "flags": [ + { + "name": "bitwave-account", + "maps": "bitwaveAccountId", + "repeat": false + } + ], + "extra": [ + { + "name": "out", + "kind": "string", + "required": false, + "requiredWith": "exportFormat", + "doc": "Where to write the returned files. One format: the path. Several: a stem, plus .csv / .qbo / .bitwave.csv." + } + ], + "custom": true, + "preset": {} + }, + "x-core-call": "wallet.getTransactions", + "x-source": "src/cli/engine/routes/transactions.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + }, + { + "name": "tokenId", + "in": "query", + "required": false, + "description": "Defaults to the native asset.", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many to return. Defaults to 100; pass `0` for every transaction from `offset` on. `total` in the response says how many matched, so a caller can page with `offset`.", + "schema": { + "type": "number" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "Where to start. Defaults to 0.", + "schema": { + "type": "number" + } + }, + { + "name": "saveExportPrefs", + "in": "query", + "required": false, + "description": "Save `bitwaveAccountId` and the chosen formats into the wallet’s synced `exportTxInfo.json`, which the GUI export scene reads back. Off by default: a read does not change saved preferences.", + "schema": { + "type": "boolean" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "ISO-8601, or epoch milliseconds.", + "schema": { + "description": "Date" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "ISO-8601, or epoch milliseconds.", + "schema": { + "description": "Date" + } + }, + { + "name": "searchString", + "in": "query", + "required": false, + "description": "Matches payee, category, notes and txid.", + "schema": { + "type": "string" + } + }, + { + "name": "spamThreshold", + "in": "query", + "required": false, + "description": "Native-amount floor. Omitted, the account spam-filter setting applies; a value always overrides it, and `0` shows everything. An empty value reads as omitted, like every other query parameter.", + "schema": { + "type": "string" + } + }, + { + "name": "fiat", + "in": "query", + "required": false, + "description": "Three-letter ISO 4217 code. Defaults to the account defaultIsoFiat.", + "schema": { + "type": "string" + } + }, + { + "name": "exportFormat", + "in": "query", + "required": false, + "description": "Comma list of `csv`, `qbo`, `bitwave`.", + "schema": { + "type": "string" + } + }, + { + "name": "bitwaveAccountId", + "in": "query", + "required": false, + "description": "A 400 unless `exportFormat` includes `bitwave`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`{ transactions, total, isoFiat }`, or `{ ok, isoFiat, total, files }` when exportFormat is set. Each transaction carries `metadata.name`, `metadata.category` and `metadata.notes` as **localized prose** in the engine’s boot locale — the engine has no per-request locale, so a second shell with a different `LANG` reuses the first engine and gets its language. `displayInfo` beside them carries the machine values the prose was derived from (`direction`, `assetActionType`, `actionType`, and the `category` split whose `category` member is one of `transfer`, `exchange`, `expense`, `income`), so a caller can render its own text.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "BAD_REQUEST, MISSING_BITWAVE_ACCOUNT_ID, TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/get-num-transactions": { + "get": { + "operationId": "getNumTransactions", + "summary": "Count transactions in a wallet.", + "description": "**Core call:** `wallet.getNumTransactions`\n\n**Command line**\n\n```\nget-num-transactions --wallet-id= [--token-id=]\n```\n\nCheaper than listing when only the total matters.", + "tags": [ + "Transactions" + ], + "x-cli": { + "command": "get-num-transactions", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.getNumTransactions", + "x-source": "src/cli/engine/routes/transactions.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + }, + { + "name": "tokenId", + "in": "query", + "required": false, + "description": "Defaults to the native asset.", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "numTransactions": { + "type": "number", + "description": "Every transaction the wallet knows of." + } + }, + "required": [ + "numTransactions" + ] + } + } + } + }, + "default": { + "description": "TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/save-tx-metadata": { + "post": { + "operationId": "saveTxMetadata", + "summary": "Save transaction metadata.", + "description": "**Core call:** `wallet.saveTxMetadata`\n\n**Command line**\n\n```\nsave-tx-metadata --wallet-id= --txid= [--token-id=] --metadata=''\n```\n\nOne of the paths that write transaction metadata to disk. The others are `save-tx-action`, which writes `savedAction` and `assetAction` to the same file, and `save-tx` and `spend`, both of which re-apply the caller's metadata through `saveTxAndMetadata` after the transaction is saved.", + "tags": [ + "Transactions" + ], + "x-cli": { + "command": "save-tx-metadata", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.saveTxMetadata", + "x-source": "src/cli/engine/routes/transactions.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "txid": { + "type": "string", + "description": "Which transaction to tag." + }, + "tokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "metadata": { + "description": "`EdgeMetadataChange`: name, category, notes, exchangeAmount, bizId. `null` on a field deletes it; omitting the field leaves it unchanged." + } + }, + "required": [ + "walletId", + "txid", + "metadata" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/save-tx-action": { + "post": { + "operationId": "saveTxAction", + "summary": "Save a transaction action.", + "description": "**Core call:** `wallet.saveTxAction`\n\n**Command line**\n\n```\nsave-tx-action --wallet-id= --txid= [--token-id=] --saved-action='' [--asset-action='']\n```\n\nRecords what a transaction *was* — a swap, a stake — beyond its metadata.", + "tags": [ + "Transactions" + ], + "x-cli": { + "command": "save-tx-action", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.saveTxAction", + "x-source": "src/cli/engine/routes/transactions.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content." + }, + "default": { + "description": "BAD_REQUEST, TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "txid": { + "type": "string", + "description": "Which transaction to annotate." + }, + "tokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "savedAction": { + "anyOf": [ + { + "description": "import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionSwap" + }, + { + "description": "import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionStake" + }, + { + "description": "import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionFiat" + }, + { + "description": "import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionTokenApproval" + }, + { + "description": "import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxActionGiftCard" + } + ], + "description": "`EdgeTxAction` describing what happened, discriminated on `actionType`: swap, stake, fiat, tokenApproval or giftCard." + }, + "assetAction": { + "description": "`EdgeAssetAction`: one `assetActionType`." + } + }, + "required": [ + "walletId", + "txid", + "savedAction" + ] + } + } + } + } + } + }, + "/account/{sessionId}/object/{objectId}": { + "get": { + "operationId": "getObject", + "summary": "Inspect an object handle.", + "description": "**Core call:** _none — Engine handle store; core identifies these values by object reference._\n\n**Command line**\n\n```\nobject-get \n```\n\nWorks for every kind: transactions, pending logins, swap quotes and lobbies.", + "tags": [ + "Object handles" + ], + "x-cli": { + "command": "object-get", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine handle store; core identifies these values by object reference.", + "x-source": "src/cli/engine/routes/objects.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "An ephemeral object handle id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The handle fields, plus a `value` holding a JSON-safe view of the object. A staged transaction is returned whole; pending logins and swap quotes are summarised, because the values behind them are live core objects; a lobby has no scalar projection and reports `value: null`.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "createdAt": { + "type": "string", + "description": "When the engine took the handle." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + } + }, + "required": [ + "objectId", + "kind", + "createdAt", + "expiresAt" + ] + } + } + } + }, + "default": { + "description": "OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_IN_USE, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/object/delete/{objectId}": { + "post": { + "operationId": "deleteObject", + "summary": "Release an object handle.", + "description": "**Core call:** _none — Engine handle store._\n\n**Command line**\n\n```\nobject-delete \n```\n\nRuns the handle's cleanup — closing a swap quote, cancelling a pending login — instead of waiting out the TTL.", + "tags": [ + "Object handles" + ], + "x-cli": { + "command": "object-delete", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine handle store.", + "x-source": "src/cli/engine/routes/objects.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "An ephemeral object handle id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "Always true; a failure arrives as an error envelope." + }, + "objectId": { + "type": "string", + "description": "The handle this call consumed. It is now expired." + } + }, + "required": [ + "ok", + "objectId" + ] + } + } + } + }, + "default": { + "description": "OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_IN_USE, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/get-max-spendable": { + "post": { + "operationId": "getMaxSpendable", + "summary": "Largest sendable amount.", + "description": "**Core call:** `wallet.getMaxSpendable`\n\n**Command line**\n\n```\nget-max-spendable --wallet-id= [--spend-info=''] [--to=] [--native-amount=] [--amount=] [--token-id=] [--metadata='']\n```\n\nWhat empties the wallet after fees. A destination is still required, since fees depend on it.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "get-max-spendable", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.getMaxSpendable", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nativeAmount": { + "type": "string", + "description": "The most this wallet can send." + } + }, + "required": [ + "nativeAmount" + ] + } + } + } + }, + "default": { + "description": "TOKEN_NOT_FOUND, INSUFFICIENT_FUNDS, BAD_REQUEST, NETWORK_ERROR, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "spendInfo": { + "anyOf": [ + { + "description": "{ spendTargets: { publicAddress: string" + }, + { + "description": "undefined; nativeAmount: string" + }, + { + "description": "undefined; uniqueIdentifier: string" + }, + { + "description": "undefined; memo: string" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }[]; tokenId: string" + }, + { + "description": "null; metadata: EdgeMetadata" + }, + { + "description": "undefined; networkFeeOption: string" + }, + { + "description": "undefined; customNetworkFee: { [keys: string]: unknown; }" + }, + { + "description": "undefined; rbfTxid: string" + }, + { + "type": "array", + "items": { + "description": "undefined; memos: { [keys: string]: unknown; }" + } + }, + { + "description": "undefined; assetAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeAssetAction" + }, + { + "description": "undefined; savedAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxAction" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }" + } + ], + "description": "A full `EdgeSpendInfo`, used as-is when present. `spendTargets` is required." + }, + "to": { + "type": "string", + "description": "Address or BIP21 URI, run through `wallet.parseUri`." + }, + "nativeAmount": { + "type": "string", + "description": "How much, in native units." + }, + "amount": { + "type": "string", + "description": "Alias of `nativeAmount`." + }, + "tokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "metadata": { + "description": "Wins over anything parsed out of the URI." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/spend": { + "post": { + "operationId": "spend", + "summary": "Send funds.", + "description": "**Core call:** _none — GUI composite: makeSpend, signTx, broadcastTx and saveTx together._\n\n**Command line**\n\n```\nspend [--use-max[=false]] [--dry-run[=false]] [--broadcast[=false]] [--save[=false]] --wallet-id= [--spend-info=''] [--to=] [--native-amount=] [--amount=] [--token-id=] [--metadata='']\n```\n\n`makeSpend`, then `signTx`, then optionally `broadcastTx` and `saveTx`, in one request. `broadcast` and `save` both default to true, so a bare body with a destination and an amount moves real money. A completed spend leaves no handle behind.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "spend", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-cli-alternates": [ + { + "command": "spend-max", + "flags": [], + "extra": [], + "custom": false, + "preset": { + "useMax": true + }, + "notes": "The same route with `useMax` preset, so it sends everything.", + "summary": "Send a wallet’s entire spendable balance." + } + ], + "x-core-call": null, + "x-core-note": "GUI composite: makeSpend, signTx, broadcastTx and saveTx together.", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`{ transaction }`, plus `saveError` when the broadcast succeeded but saving failed. With dryRun, a TransactionHandle instead.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "TOKEN_NOT_FOUND, INSUFFICIENT_FUNDS, DUST_SPEND, PENDING_FUNDS, SPEND_TO_SELF, NO_AMOUNT_SPECIFIED, BAD_REQUEST, NETWORK_ERROR, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "useMax": { + "type": "boolean", + "description": "Replace the first target’s amount with the maximum." + }, + "dryRun": { + "type": "boolean", + "description": "Build only. Never signs or broadcasts." + }, + "broadcast": { + "type": "boolean", + "description": "Defaults to **true**. With `false` the transaction is signed and not sent, so `save` then defaults to false as well — recording an unsent transaction marks its inputs spent locally for something the network will never confirm." + }, + "save": { + "type": "boolean", + "description": "Record the transaction in the wallet. Defaults to whatever `broadcast` is. Setting it true alongside `broadcast: false` is refused: use `--dry-run`, or the staged `make-spend` → `sign-tx` → `broadcast-tx` → `save-tx` flow." + }, + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "spendInfo": { + "anyOf": [ + { + "description": "{ spendTargets: { publicAddress: string" + }, + { + "description": "undefined; nativeAmount: string" + }, + { + "description": "undefined; uniqueIdentifier: string" + }, + { + "description": "undefined; memo: string" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }[]; tokenId: string" + }, + { + "description": "null; metadata: EdgeMetadata" + }, + { + "description": "undefined; networkFeeOption: string" + }, + { + "description": "undefined; customNetworkFee: { [keys: string]: unknown; }" + }, + { + "description": "undefined; rbfTxid: string" + }, + { + "type": "array", + "items": { + "description": "undefined; memos: { [keys: string]: unknown; }" + } + }, + { + "description": "undefined; assetAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeAssetAction" + }, + { + "description": "undefined; savedAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxAction" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }" + } + ], + "description": "A full `EdgeSpendInfo`, used as-is when present. `spendTargets` is required." + }, + "to": { + "type": "string", + "description": "Address or BIP21 URI, run through `wallet.parseUri`." + }, + "nativeAmount": { + "type": "string", + "description": "How much, in native units." + }, + "amount": { + "type": "string", + "description": "Alias of `nativeAmount`." + }, + "tokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "metadata": { + "description": "Wins over anything parsed out of the URI." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/make-spend": { + "post": { + "operationId": "makeSpend", + "summary": "Build an unsigned transaction.", + "description": "**Core call:** `wallet.makeSpend`\n\n**Command line**\n\n```\nmake-spend --wallet-id= [--spend-info=''] [--to=] [--native-amount=] [--amount=] [--token-id=] [--metadata='']\n```\n\nFirst step of the staged workflow: nothing is signed and no funds move. Inspect `transaction.networkFee` on the result before signing.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "make-spend", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.makeSpend", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + }, + "transaction": { + "description": "`EdgeTransaction` as it stands after this step. Unsigned after `make-spend`, signed after `sign-tx`, and carrying a txid once broadcast." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "transaction" + ] + } + } + } + }, + "default": { + "description": "TOKEN_NOT_FOUND, INSUFFICIENT_FUNDS, DUST_SPEND, NO_AMOUNT_SPECIFIED, BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "spendInfo": { + "anyOf": [ + { + "description": "{ spendTargets: { publicAddress: string" + }, + { + "description": "undefined; nativeAmount: string" + }, + { + "description": "undefined; uniqueIdentifier: string" + }, + { + "description": "undefined; memo: string" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }[]; tokenId: string" + }, + { + "description": "null; metadata: EdgeMetadata" + }, + { + "description": "undefined; networkFeeOption: string" + }, + { + "description": "undefined; customNetworkFee: { [keys: string]: unknown; }" + }, + { + "description": "undefined; rbfTxid: string" + }, + { + "type": "array", + "items": { + "description": "undefined; memos: { [keys: string]: unknown; }" + } + }, + { + "description": "undefined; assetAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeAssetAction" + }, + { + "description": "undefined; savedAction: import(\"/Users/paul/git-worktrees/edge-react-gui-cli/node_modules/edge-core-js/src/types/types\").EdgeTxAction" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }" + } + ], + "description": "A full `EdgeSpendInfo`, used as-is when present. `spendTargets` is required." + }, + "to": { + "type": "string", + "description": "Address or BIP21 URI, run through `wallet.parseUri`." + }, + "nativeAmount": { + "type": "string", + "description": "How much, in native units." + }, + "amount": { + "type": "string", + "description": "Alias of `nativeAmount`." + }, + "tokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "metadata": { + "description": "Wins over anything parsed out of the URI." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/sign-tx/{objectId}": { + "post": { + "operationId": "signTx", + "summary": "Sign a staged transaction.", + "description": "**Core call:** `wallet.signTx`\n\n**Command line**\n\n```\nsign-tx \n```\n\nKeeps the same handle and pushes its expiry out another five minutes.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "sign-tx", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.signTx", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "From `make-spend`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + }, + "transaction": { + "description": "`EdgeTransaction` as it stands after this step. Unsigned after `make-spend`, signed after `sign-tx`, and carrying a txid once broadcast." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "transaction" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_WALLET_MISMATCH, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/broadcast-tx/{objectId}": { + "post": { + "operationId": "broadcastTx", + "summary": "Broadcast a signed transaction.", + "description": "**Core call:** `wallet.broadcastTx`\n\n**Command line**\n\n```\nbroadcast-tx \n```\n\nThe irreversible step: once this returns, the funds have left the wallet.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "broadcast-tx", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.broadcastTx", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "From `sign-tx`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The handle survives, so `save-tx` can still run.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + }, + "transaction": { + "description": "`EdgeTransaction` as it stands after this step. Unsigned after `make-spend`, signed after `sign-tx`, and carrying a txid once broadcast." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "transaction" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_WALLET_MISMATCH, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/save-tx/{objectId}": { + "post": { + "operationId": "saveTx", + "summary": "Record a transaction and release its handle.", + "description": "**Core call:** `wallet.saveTx`\n\n**Command line**\n\n```\nsave-tx \n```\n\nFinal step. The handle is gone afterwards, so a second call is a 404.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "save-tx", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.saveTx", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "The handle to persist and release.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "Always true; a failure arrives as an error envelope." + }, + "objectId": { + "type": "string", + "description": "The handle this call consumed. It is now expired." + } + }, + "required": [ + "ok", + "objectId" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_WALLET_MISMATCH, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/accelerate": { + "post": { + "operationId": "accelerate", + "summary": "Fee-bump a pending transaction.", + "description": "**Core call:** `wallet.accelerate`\n\n**Command line**\n\n```\naccelerate --wallet-id= [--object-id=] [--transaction='']\n```\n\nReplace-by-fee, where the plugin supports it. Returns a new unsigned transaction to sign and broadcast.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "accelerate", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.accelerate", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Given objectId the same handle is updated; given a transaction a new one is created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + }, + "transaction": { + "description": "`EdgeTransaction` as it stands after this step. Unsigned after `make-spend`, signed after `sign-tx`, and carrying a txid once broadcast." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "transaction" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_WALLET_MISMATCH, OBJECT_SESSION_MISMATCH, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "objectId": { + "type": "string", + "description": "Handle of the transaction to bump." + }, + "transaction": { + "anyOf": [ + { + "description": "{ txid: string; currencyCode: string" + }, + { + "description": "undefined; nativeAmount: string" + }, + { + "description": "undefined; networkFee: string" + }, + { + "description": "undefined; walletId: string" + }, + { + "description": "undefined; }" + } + ], + "description": "Or the transaction itself." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/sweep-private-keys": { + "post": { + "operationId": "sweepPrivateKeys", + "summary": "Sweep private keys into this wallet.", + "description": "**Core call:** `wallet.sweepPrivateKeys`\n\n**Command line**\n\n```\nsweep-private-keys --wallet-id= --spend-info=''\n```\n\nBuilds a transaction moving everything from an external key. Returns an unsigned handle: sign, broadcast and save it like any staged spend.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "sweep-private-keys", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.sweepPrivateKeys", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "sessionId": { + "type": "string", + "description": "Session that created the handle; only that session may use it." + }, + "walletId": { + "type": "string", + "description": "Wallet the handle is bound to, when it belongs to one." + }, + "transaction": { + "description": "`EdgeTransaction` as it stands after this step. Unsigned after `make-spend`, signed after `sign-tx`, and carrying a txid once broadcast." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "transaction" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, INSUFFICIENT_FUNDS, NETWORK_ERROR, TOKEN_NOT_FOUND, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "spendInfo": { + "anyOf": [ + { + "description": "{ privateKeys: string[]; tokenId: string" + }, + { + "description": "null; spendTargets: { publicAddress: string" + }, + { + "description": "undefined; nativeAmount: string" + }, + { + "description": "undefined; uniqueIdentifier: string" + }, + { + "description": "undefined; memo: string" + }, + { + "description": "undefined; otherParams: { [keys: string]: unknown; }" + }, + { + "description": "undefined; }[]; metadata: EdgeMetadata" + }, + { + "type": "array", + "items": { + "description": "undefined; memos: { [keys: string]: unknown; }" + } + }, + { + "description": "undefined; }" + } + ], + "description": "The keys to sweep in `privateKeys`, plus the optional `spendTargets`, `tokenId`, `metadata` and `memos` of a spend." + } + }, + "required": [ + "walletId", + "spendInfo" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/sign-bytes": { + "post": { + "operationId": "signBytes", + "summary": "Sign arbitrary bytes.", + "description": "**Core call:** `wallet.signBytes`\n\n**Command line**\n\n```\nsign-bytes --wallet-id= [--bytes=] [--other-params='']\n```\n\nMessage signing and proof-of-ownership, for plugins that support it.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "sign-bytes", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.signBytes", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string", + "description": "Base64." + } + }, + "required": [ + "signature" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "bytes": { + "type": "string", + "description": "Base64. Defaults to empty when absent." + }, + "otherParams": { + "description": "Plugin-specific options. Bitcoin needs `{ publicAddress }`; other plugins take nothing, or refuse the call entirely." + } + }, + "required": [ + "walletId" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/get-payment-protocol-info": { + "get": { + "operationId": "getPaymentProtocolInfo", + "summary": "Fetch a BIP70 payment request.", + "description": "**Core call:** `wallet.getPaymentProtocolInfo`\n\n**Command line**\n\n```\nget-payment-protocol-info --wallet-id= --payment-protocol-url=\n```\n\nFeed `spendTargets` from the result into `make-spend` to pay it.", + "tags": [ + "Spending" + ], + "x-cli": { + "command": "get-payment-protocol-info", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.getPaymentProtocolInfo", + "x-source": "src/cli/engine/routes/spend.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "walletId", + "in": "query", + "required": true, + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`.", + "schema": { + "type": "string" + } + }, + { + "name": "paymentProtocolUrl", + "in": "query", + "required": true, + "description": "The payment-request URL.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`EdgePaymentProtocolInfo`: domain, memo, merchant, nativeAmount, spendTargets.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "BAD_REQUEST, NETWORK_ERROR, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/fetch-swap-quotes": { + "post": { + "operationId": "fetchSwapQuotes", + "summary": "Fetch swap quotes.", + "description": "**Core call:** `account.fetchSwapQuotes`\n\n**Command line**\n\n```\nfetch-swap-quotes --from-wallet-id= --to-wallet-id= --native-amount= [--from-token-id=] [--to-token-id=] [--quote-for=from|max|to] [--plugin-id=]\n```\n\nPolls every enabled swap plugin and parks each result under its own `swap_` handle with a 5 minute TTL.", + "tags": [ + "Swap quotes" + ], + "x-cli": { + "command": "fetch-swap-quotes", + "flags": [ + { + "name": "plugin-id", + "maps": "preferPluginId", + "repeat": false + } + ], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "account.fetchSwapQuotes", + "x-source": "src/cli/engine/routes/swap.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quoteCount": { + "type": "number", + "description": "How many plugins answered." + }, + "quotes": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "{ objectId: string; kind: string; expiresAt: string; pluginId: string; isEstimate: boolean; canBePartial: boolean" + }, + { + "description": "null; maxFulfillmentSeconds: number" + }, + { + "description": "null; minReceiveAmount: string" + }, + { + "description": "null; fromNativeAmount: string; toNativeAmount: string; networkFee: { nativeAmount: string; tokenId: string" + }, + { + "description": "null; }; quoteExpirationDate: string" + }, + { + "description": "null; swapInfo: { pluginId: string; displayName: string; supportEmail: string; isDex: boolean" + }, + { + "description": "null; }; request: { fromTokenId: string" + }, + { + "description": "null; toTokenId: string" + }, + { + "description": "null; nativeAmount: string; quoteFor: \"from\"" + }, + { + "description": "\"to\"" + }, + { + "description": "\"max\"; fromWalletId: string; toWalletId: string; }; }" + } + ] + }, + "description": "One quote per plugin that answered, each already parked under its own handle. Plugins that failed or had nothing to offer are simply absent." + } + }, + "required": [ + "quoteCount", + "quotes" + ] + } + } + } + }, + "default": { + "description": "BAD_REQUEST, SWAP_BELOW_LIMIT, SWAP_ABOVE_LIMIT, SWAP_CURRENCY, SWAP_PERMISSION, SWAP_ADDRESS, SAME_CURRENCY, INSUFFICIENT_FUNDS, WALLET_NOT_FOUND, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fromWalletId": { + "type": "string", + "description": "Source wallet. Accepts a unique prefix." + }, + "toWalletId": { + "type": "string", + "description": "Destination wallet." + }, + "nativeAmount": { + "type": "string", + "description": "How much, in native units." + }, + "fromTokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "toTokenId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Defaults to the native asset." + }, + "quoteFor": { + "anyOf": [ + { + "description": "\"from\"" + }, + { + "description": "\"max\"" + }, + { + "description": "\"to\"" + } + ], + "description": "`from` spends this much of the source, `to` receives this much at the destination, `max` sends everything. Defaults to `from`." + }, + "preferPluginId": { + "type": "string", + "description": "Restrict to one exchange." + } + }, + "required": [ + "fromWalletId", + "toWalletId", + "nativeAmount" + ] + } + } + } + } + } + }, + "/account/{sessionId}/swap-quote/{objectId}": { + "get": { + "operationId": "getSwapQuote", + "summary": "Re-read a quote.", + "description": "**Core call:** _none — Engine handle store; the quote is a live EdgeSwapQuote held server-side._\n\n**Command line**\n\n```\nswap-quote-get \n```\n\n", + "tags": [ + "Swap quotes" + ], + "x-cli": { + "command": "swap-quote-get", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": null, + "x-core-note": "Engine handle store; the quote is a live EdgeSwapQuote held server-side.", + "x-source": "src/cli/engine/routes/swap.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "An ephemeral object handle id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "objectId": { + "type": "string", + "description": "Handle for the value the engine is holding. Pass it to the calls that consume it." + }, + "kind": { + "type": "string", + "description": "What the handle refers to, which decides the calls that accept it." + }, + "expiresAt": { + "type": "string", + "description": "When the engine drops the handle. Handles live 5 minutes." + }, + "pluginId": { + "type": "string", + "description": "Swap provider that produced this quote." + }, + "isEstimate": { + "type": "boolean", + "description": "True when the provider may settle at a different rate than quoted." + }, + "canBePartial": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "True when the provider may fill only part of the order. Null when it does not say." + }, + "maxFulfillmentSeconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Longest the provider expects a partial fill to take." + }, + "minReceiveAmount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Least the provider guarantees to deliver, in the destination’s native units." + }, + "fromNativeAmount": { + "type": "string", + "description": "Amount leaving the source wallet." + }, + "toNativeAmount": { + "type": "string", + "description": "Amount arriving in the destination wallet." + }, + "networkFee": { + "anyOf": [ + { + "description": "{ nativeAmount: string; tokenId: string" + }, + { + "description": "null; }" + } + ], + "description": "On-chain fee for the sending transaction. It is not the provider’s own spread, which is already in the rate." + }, + "quoteExpirationDate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the provider stops honouring the rate. Null when it does not expire." + }, + "swapInfo": { + "anyOf": [ + { + "description": "{ pluginId: string; displayName: string; supportEmail: string; isDex: boolean" + }, + { + "description": "null; }" + } + ], + "description": "`EdgeSwapInfo`: how to name the provider and where to send complaints." + }, + "request": { + "anyOf": [ + { + "description": "{ fromTokenId: string" + }, + { + "description": "null; toTokenId: string" + }, + { + "description": "null; nativeAmount: string; quoteFor: \"from\"" + }, + { + "description": "\"to\"" + }, + { + "description": "\"max\"; fromWalletId: string; toWalletId: string; }" + } + ], + "description": "The `EdgeSwapRequest` this quote answers, echoed back so quotes from different plugins can be compared without tracking what was asked." + } + }, + "required": [ + "objectId", + "kind", + "expiresAt", + "pluginId", + "isEstimate", + "canBePartial", + "maxFulfillmentSeconds", + "minReceiveAmount", + "fromNativeAmount", + "toNativeAmount", + "networkFee", + "quoteExpirationDate", + "swapInfo", + "request" + ] + } + } + } + }, + "default": { + "description": "OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/swap-quote/approve/{objectId}": { + "post": { + "operationId": "approveSwapQuote", + "summary": "Execute a quote.", + "description": "**Core call:** `EdgeSwapQuote.approve`\n\n**Command line**\n\n```\napprove-swap-quote \n```\n\nMoves funds. The handle is released afterwards whether or not the response is read, so record `orderId` from it.", + "tags": [ + "Swap quotes" + ], + "x-cli": { + "command": "approve-swap-quote", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "EdgeSwapQuote.approve", + "x-source": "src/cli/engine/routes/swap.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "An ephemeral object handle id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "description": "True once the swap is submitted and the send broadcast." + }, + "objectId": { + "type": "string", + "description": "The handle that was consumed." + }, + "orderId": { + "description": "The exchange’s order reference, when it gives one." + }, + "destinationAddress": { + "description": "Address the funds were sent to, when the exchange reports one." + }, + "transaction": { + "description": "The on-chain send to the exchange." + } + }, + "required": [ + "ok", + "objectId", + "orderId", + "destinationAddress", + "transaction" + ] + } + } + } + }, + "default": { + "description": "OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_SESSION_MISMATCH, OBJECT_IN_USE, INSUFFICIENT_FUNDS, NETWORK_ERROR, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/swap-quote/close/{objectId}": { + "post": { + "operationId": "closeSwapQuote", + "summary": "Discard a quote.", + "description": "**Core call:** `EdgeSwapQuote.close`\n\n**Command line**\n\n```\nclose-swap-quote \n```\n\nCloses the plugin object without executing, freeing whatever the exchange was holding.", + "tags": [ + "Swap quotes" + ], + "x-cli": { + "command": "close-swap-quote", + "positional": "objectId", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "EdgeSwapQuote.close", + "x-source": "src/cli/engine/routes/swap.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "An ephemeral object handle id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "description": "Always true; a failure arrives as an error envelope." + }, + "objectId": { + "type": "string", + "description": "The handle this call consumed. It is now expired." + } + }, + "required": [ + "ok", + "objectId" + ] + } + } + } + }, + "default": { + "description": "OBJECT_NOT_FOUND, OBJECT_EXPIRED, OBJECT_KIND_MISMATCH, OBJECT_SESSION_MISMATCH, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/account/{sessionId}/wallet/parse-uri": { + "post": { + "operationId": "parseUri", + "summary": "Parse a payment URI or address.", + "description": "**Core call:** `wallet.parseUri`\n\n**Command line**\n\n```\nparse-uri --wallet-id= --uri= [--currency-code=]\n```\n\nWhat the GUI address tile does when you paste or scan something.", + "tags": [ + "URIs" + ], + "x-cli": { + "command": "parse-uri", + "flags": [], + "extra": [], + "custom": false, + "preset": {} + }, + "x-core-call": "wallet.parseUri", + "x-source": "src/cli/engine/routes/uri.ts", + "parameters": [ + { + "name": "sessionId", + "in": "path", + "required": true, + "description": "From a successful login. The CLI supplies this from `session.json`, `--session`, or `EDGE_CLI_SESSION`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`EdgeParsedUri`: publicAddress, nativeAmount, currencyCode, metadata, paymentProtocolUrl, …", + "content": { + "application/json": { + "schema": {} + } + } + }, + "default": { + "description": "BAD_REQUEST, WALLET_NOT_FOUND, AMBIGUOUS_WALLET_ID, INVALID_SESSION, SESSION_EXPIRED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletId": { + "type": "string", + "description": "The wallet to act on. A full wallet id, or any unique prefix of one. An ambiguous prefix returns `409 AMBIGUOUS_WALLET_ID` with `details.candidates`." + }, + "uri": { + "type": "string", + "description": "A payment URI or a bare address." + }, + "currencyCode": { + "type": "string", + "description": "Disambiguates on chains that carry several assets." + } + }, + "required": [ + "walletId", + "uri" + ] + } + } + } + } + } + }, + "/account/{sessionId}/wallet/encode-uri": { + "post": { + "operationId": "encodeUri", + "summary": "Build a payment URI.", + "description": "**Core call:** `wallet.encodeUri`\n\n**Command line**\n\n```\nencode-uri --wallet-id= --public-address= [--native-amount=] [--label=